Micron Document
πŸŽ–οΈGitΠ―Ρ€Π°πŸŽ–οΈ

Node / meshtastic / Meshtastic-Android / files / core / service / src / commonTest / kotlin / org / meshtastic / core / service / SharedRadioInterfaceServiceLivenessTest.kt

Displaying Raw β€’ Download

core/service/src/commonTest/kotlin/org/meshtastic/core/service/SharedRadioInterfaceServiceLivenessTest.kt 1f0d40bad1707c0e6cf3463b78ea5f19da3ffe44 (1f0d40ba) Text, 107.36 KB

T8b949e/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
Tff7b72package T7ee787org.meshtastic.core.service

Tff7b72import T7ee787androidx.lifecycle.Lifecycle
Tff7b72import T7ee787androidx.lifecycle.LifecycleEventObserver
Tff7b72import T7ee787androidx.lifecycle.LifecycleObserver
Tff7b72import T7ee787androidx.lifecycle.LifecycleOwner
Tff7b72import T7ee787dev.mokkery.MockMode
Tff7b72import T7ee787dev.mokkery.answering.calls
Tff7b72import T7ee787dev.mokkery.answering.returns
Tff7b72import T7ee787dev.mokkery.every
Tff7b72import T7ee787dev.mokkery.matcher.any
Tff7b72import T7ee787dev.mokkery.mock
Tff7b72import T7ee787kotlinx.coroutines.CompletableDeferred
Tff7b72import T7ee787kotlinx.coroutines.CoroutineStart
Tff7b72import T7ee787kotlinx.coroutines.Dispatchers
Tff7b72import T7ee787kotlinx.coroutines.ExperimentalCoroutinesApi
Tff7b72import T7ee787kotlinx.coroutines.async
Tff7b72import T7ee787kotlinx.coroutines.flow.MutableSharedFlow
Tff7b72import T7ee787kotlinx.coroutines.flow.MutableStateFlow
Tff7b72import T7ee787kotlinx.coroutines.flow.StateFlow
Tff7b72import T7ee787kotlinx.coroutines.flow.asStateFlow
Tff7b72import T7ee787kotlinx.coroutines.flow.first
Tff7b72import T7ee787kotlinx.coroutines.launch
Tff7b72import T7ee787kotlinx.coroutines.test.UnconfinedTestDispatcher
Tff7b72import T7ee787kotlinx.coroutines.test.advanceTimeBy
Tff7b72import T7ee787kotlinx.coroutines.test.resetMain
Tff7b72import T7ee787kotlinx.coroutines.test.runTest
Tff7b72import T7ee787kotlinx.coroutines.test.setMain
Tff7b72import T7ee787org.meshtastic.core.di.CoroutineDispatchers
Tff7b72import T7ee787org.meshtastic.core.model.ConnectionState
Tff7b72import T7ee787org.meshtastic.core.model.DeviceType
Tff7b72import T7ee787org.meshtastic.core.network.repository.NetworkRepository
Tff7b72import T7ee787org.meshtastic.core.network.repository.SerialDevicePresence
Tff7b72import T7ee787org.meshtastic.core.repository.PlatformAnalytics
Tff7b72import T7ee787org.meshtastic.core.repository.RadioInterfaceService
Tff7b72import T7ee787org.meshtastic.core.repository.RadioTransport
Tff7b72import T7ee787org.meshtastic.core.repository.RadioTransportFactory
Tff7b72import T7ee787org.meshtastic.core.repository.TransportDisconnectReason
Tff7b72import T7ee787org.meshtastic.core.testing.FakeBluetoothRepository
Tff7b72import T7ee787org.meshtastic.core.testing.FakeRadioPrefs
Tff7b72import T7ee787org.meshtastic.core.testing.FakeRadioTransport
Tff7b72import T7ee787kotlin.test.AfterTest
Tff7b72import T7ee787kotlin.test.BeforeTest
Tff7b72import T7ee787kotlin.test.Test
Tff7b72import T7ee787kotlin.test.assertContentEquals
Tff7b72import T7ee787kotlin.test.assertEquals
Tff7b72import T7ee787kotlin.test.assertFalse
Tff7b72import T7ee787kotlin.test.assertNull
Tff7b72import T7ee787kotlin.test.assertTrue

T8b949e/**
* Service-level tests for [SharedRadioInterfaceService] liveness detection.
*
* Uses a controllable clock via [SharedRadioInterfaceService.clockMillis] so [onConnect], [handleFromRadio], and
* [checkLiveness] all share one coherent time source β€” no mixing of real wall-clock with test time.
*
* A counting transport factory returns a fresh [FakeRadioTransport] per createTransport() call so we can observe how
* many restarts actually occurred.
*/
Tf0883e@OptInTb4b4b4(Te6edf3ExperimentalCoroutinesApiTff7b72::Te6edf3classTb4b4b4)
Tff7b72class T56d364SharedRadioInterfaceServiceLivenessTest Tb4b4b4{

Tff7b72private Tff7b72val Te6edf3testDispatcher Tff7b72= Te6edf3UnconfinedTestDispatcherTb4b4b4(Tb4b4b4)
Tff7b72private Tff7b72val Te6edf3dispatchers Tff7b72= Te6edf3CoroutineDispatchersTb4b4b4(Te6edf3io Tff7b72= Te6edf3testDispatcherTb4b4b4, Te6edf3main Tff7b72= Te6edf3testDispatcherTb4b4b4, Te6edf3default Tff7b72= Te6edf3testDispatcherTb4b4b4)

Tff7b72private Tff7b72lateinit Tff7b72var Te6edf3processLifecycleOwnerTb4b4b4: Te6edf3TestLifecycleOwner

Tf0883e@Test
Tff7b72fun Td2a8ff`transport session diagnostic redacts its address`Tb4b4b4(Tb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3rawAddress Tff7b72= Ta5d6ff"Ta5d6ffxAA:BB:CC:DD:EE:FFTa5d6ff"

Tff7b72val Te6edf3diagnostic Tff7b72= Te6edf3RadioTransportSessionTb4b4b4(Te6edf3generation Tff7b72= T79c0ff7LTb4b4b4, Te6edf3address Tff7b72= Te6edf3rawAddressTb4b4b4)Tb4b4b4.Te6edf3toStringTb4b4b4(Tb4b4b4)

Te6edf3assertFalseTb4b4b4(Te6edf3rawAddress Tff7b72in Te6edf3diagnosticTb4b4b4)
Te6edf3assertTrueTb4b4b4(Ta5d6ff"Ta5d6ffgeneration=7Ta5d6ff" Tff7b72in Te6edf3diagnosticTb4b4b4)
Te6edf3assertTrueTb4b4b4(Ta5d6ff"Ta5d6ffaddress=...Ta5d6ff" Tff7b72in Te6edf3diagnosticTb4b4b4)
Tb4b4b4}

Tf0883e@BeforeTest
Tff7b72fun Td2a8ffsetUpTb4b4b4(Tb4b4b4) Tb4b4b4{
T8b949e// processLifecycle.coroutineScope uses Dispatchers.Main.immediate internally;
T8b949e// JVM tests must install a Main dispatcher or get IllegalStateException.
Te6edf3DispatchersTb4b4b4.Te6edf3setMainTb4b4b4(Te6edf3testDispatcherTb4b4b4)
T8b949e// Create the lifecycle owner AFTER setMain so Robolectric's main thread is ready.
T8b949e// Field initializers run before @BeforeTest, which is too early for Robolectric.
Te6edf3processLifecycleOwner Tff7b72= Te6edf3TestLifecycleOwnerTb4b4b4(Tb4b4b4)
T8b949e// USB tests leave serialDeviceKeys non-empty; reset before each test so non-USB
T8b949e// tests start from the documented empty default.
Te6edf3serialDeviceKeysTb4b4b4.Te6edf3value Tff7b72= Te6edf3emptySetTb4b4b4(Tb4b4b4)
Te6edf3bluetoothRepositoryTb4b4b4.Te6edf3setBluetoothEnabledTb4b4b4(Tff7b72trueTb4b4b4)
Tb4b4b4}

Tf0883e@AfterTest
Tff7b72fun Td2a8fftearDownTb4b4b4(Tb4b4b4) Tb4b4b4{
T8b949e// Release any suspended close gate so a held in-flight restart can complete; otherwise
T8b949e// disconnect() below would block forever on the gated transport's close().
T8b949e// NOTE: relies on UnconfinedTestDispatcher resuming the gated close() inline when
T8b949e// complete(Unit) is called β€” if testDispatcher is ever changed to StandardTestDispatcher,
T8b949e// add testDispatcher.scheduler.runCurrent() here before runBlocking to avoid a mutex deadlock.
Te6edf3activeCloseGateTff7b72?.Te6edf3completeTb4b4b4(Tffa657UnitTb4b4b4)
Te6edf3activeCloseGate Tff7b72= Tff7b72null
T8b949e// Service cleanup is handled per-test in try/finally blocks β€” each test calls
T8b949e// service.disconnect() + advanceTimeBy in a finally clause. tearDown cannot use
T8b949e// runBlocking { services.forEach { it.disconnect() } } because it deadlocks on
T8b949e// Robolectric's main thread (androidHostTest target).
Te6edf3servicesTb4b4b4.Te6edf3clearTb4b4b4(Tb4b4b4)
Te6edf3createdTransportsTb4b4b4.Te6edf3clearTb4b4b4(Tb4b4b4)
T8b949e// CRITICAL: Destroy the lifecycle to cancel processLifecycle.coroutineScope and all
T8b949e// leaked collectors (devAddr, bluetoothRepository.state, networkRepository.networkAvailable).
T8b949e// Without this, those infinite flow collectors keep the forked test JVM alive after tests
T8b949e// complete, causing Gradle to hang at subsequent :core:*:allTests tasks.
Te6edf3processLifecycleOwnerTb4b4b4.Te6edf3destroyTb4b4b4(Tb4b4b4)
T8b949e// Let pending cancellations propagate before resetting the Main dispatcher. Use runCurrent
T8b949e// (NOT advanceUntilIdle): the test bodies already disconnect every service, so no heartbeat
T8b949e// loop should be active here β€” advanceUntilIdle would hang if one somehow survived.
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3DispatchersTb4b4b4.Te6edf3resetMainTb4b4b4(Tb4b4b4)
Tb4b4b4}

Tff7b72private Tff7b72val Te6edf3bluetoothRepository Tff7b72= Te6edf3FakeBluetoothRepositoryTb4b4b4(Tb4b4b4)
Tff7b72private Tff7b72val Te6edf3radioPrefs Tff7b72= Te6edf3FakeRadioPrefsTb4b4b4(Tb4b4b4)

T8b949e/**
* Controllable backing flow for [serialDevicePresence]. Defaults to the empty set so liveness/gate-regression tests
* (which exercise BLE/TCP paths only) leave the USB recovery observer inert. USB replug tests drive this flow
* directly to exercise each branch of the observer's gate contract.
*/
Tff7b72private Tff7b72val Te6edf3serialDeviceKeys Tff7b72= Te6edf3MutableStateFlowTff7b72<Te6edf3SetTff7b72<Tffa657StringTff7b72>Tff7b72>Tb4b4b4(Te6edf3emptySetTb4b4b4(Tb4b4b4)Tb4b4b4)

T8b949e/** [SerialDevicePresence] backed by [serialDeviceKeys] so tests can publish device-key sets on demand. */
Tff7b72private Tff7b72val Te6edf3serialDevicePresenceTb4b4b4: Te6edf3SerialDevicePresence Tff7b72=
Tff7b72object Tb4b4b4: T56d364SerialDevicePresence Tb4b4b4{
Tff7b72override Tff7b72val Te6edf3deviceKeysTb4b4b4: Te6edf3StateFlowTff7b72<Te6edf3SetTff7b72<Tffa657StringTff7b72>Tff7b72> Tff7b72= Te6edf3serialDeviceKeysTb4b4b4.Te6edf3asStateFlowTb4b4b4(Tb4b4b4)
Tb4b4b4}

Tff7b72private Tff7b72val Te6edf3networkRepositoryTb4b4b4: Te6edf3NetworkRepository Tff7b72= Te6edf3mockTb4b4b4(Te6edf3MockModeTb4b4b4.Te6edf3autofillTb4b4b4)
Tff7b72private Tff7b72val Te6edf3analyticsTb4b4b4: Te6edf3PlatformAnalytics Tff7b72= Te6edf3mockTb4b4b4(Te6edf3MockModeTb4b4b4.Te6edf3autofillTb4b4b4)

T8b949e/**
* Minimal [LifecycleOwner] for tests that avoids [LifecycleRegistry], which enforces main-thread checks and throws
* `RuntimeException` under Robolectric (androidHostTest). This custom [Lifecycle] dispatches ON_DESTROY to
* registered [LifecycleEventObserver]s so `lifecycleScope` cancels correctly.
*/
Tff7b72private Tff7b72class T56d364TestLifecycleOwner Tb4b4b4: Te6edf3LifecycleOwner Tb4b4b4{
Tff7b72private Tff7b72val Te6edf3observers Tff7b72= Te6edf3mutableListOfTff7b72<Te6edf3LifecycleObserverTff7b72>Tb4b4b4(Tb4b4b4)
Tff7b72private Tff7b72var Te6edf3state Tff7b72= Te6edf3LifecycleTb4b4b4.Te6edf3StateTb4b4b4.Te6edf3RESUMED

Tff7b72override Tff7b72val Te6edf3lifecycleTb4b4b4: Te6edf3Lifecycle Tff7b72=
Tff7b72object Tb4b4b4: T56d364LifecycleTb4b4b4(Tb4b4b4) Tb4b4b4{
Tff7b72override Tff7b72fun Td2a8ffaddObserverTb4b4b4(Te6edf3observerTb4b4b4: Te6edf3LifecycleObserverTb4b4b4) Tb4b4b4{
Te6edf3observersTb4b4b4.Te6edf3addTb4b4b4(Te6edf3observerTb4b4b4)
Tb4b4b4}

Tff7b72override Tff7b72fun Td2a8ffremoveObserverTb4b4b4(Te6edf3observerTb4b4b4: Te6edf3LifecycleObserverTb4b4b4) Tb4b4b4{
Te6edf3observersTb4b4b4.Te6edf3removeTb4b4b4(Te6edf3observerTb4b4b4)
Tb4b4b4}

Tff7b72override Tff7b72val Te6edf3currentStateTb4b4b4: Te6edf3LifecycleTb4b4b4.Te6edf3State
Tff7b72getTb4b4b4(Tb4b4b4) Tff7b72= Te6edf3state
Tb4b4b4}

Tff7b72fun Td2a8ffdestroyTb4b4b4(Tb4b4b4) Tb4b4b4{
Te6edf3state Tff7b72= Te6edf3LifecycleTb4b4b4.Te6edf3StateTb4b4b4.Te6edf3DESTROYED
Tff7b72val Te6edf3event Tff7b72= Te6edf3LifecycleTb4b4b4.Te6edf3EventTb4b4b4.Te6edf3ON_DESTROY
Te6edf3observersTb4b4b4.Te6edf3toListTb4b4b4(Tb4b4b4)Tb4b4b4.Te6edf3forEach Tb4b4b4{ Te6edf3observer Tff7b72-Tff7b72>
Tb4b4b4(Te6edf3observer Tff7b72as? Te6edf3LifecycleEventObserverTb4b4b4)Tff7b72?.Te6edf3onStateChangedTb4b4b4(Tff7b72thisTf0883e@TestLifecycleOwnerTb4b4b4, Te6edf3eventTb4b4b4)
Tb4b4b4}
Tb4b4b4}
Tb4b4b4}

T8b949e/**
* Test-only [RadioTransport] whose [close] suspends on a [CompletableDeferred] gate.
*
* The liveness restart path calls `stopTransportLocked` β†’ `currentTransport.close()` inside a launched coroutine.
* With the default [FakeRadioTransport], `close()` returns without suspending, so under [UnconfinedTestDispatcher]
* the entire restart completes synchronously during `checkLiveness()` and a second `checkLiveness()` never observes
* an in-flight restart. By awaiting a gate inside `close()`, this fake holds the restart genuinely suspended
* mid-flight, letting a test deterministically exercise the in-flight overlap window and prove the second check
* does not stack another restart/close.
*
* The gate is shared across instances; once completed by the test, any pending or subsequent `close()` resumes
* immediately.
*/
Tff7b72private Tff7b72class T56d364GatedFakeRadioTransportTb4b4b4(Tff7b72private Tff7b72val Te6edf3closeGateTb4b4b4: Te6edf3CompletableDeferredTff7b72<Tffa657UnitTff7b72>Tb4b4b4) Tb4b4b4: Te6edf3RadioTransport Tb4b4b4{
Tff7b72var Te6edf3closeCalled Tff7b72= Tff7b72false
Te6edf3private Tff7b72set

Tff7b72var Te6edf3closeCount Tff7b72= T79c0ff0
Te6edf3private Tff7b72set

Tff7b72var Te6edf3closeCompletedCount Tff7b72= T79c0ff0
Te6edf3private Tff7b72set

T8b949e// Liveness restart skips the polite-disconnect frame (sendPoliteDisconnect = false), so no
T8b949e// outbound data is expected; satisfy the contract with a no-op.
Tff7b72override Tff7b72fun Td2a8ffhandleSendToRadioTb4b4b4(Te6edf3pTb4b4b4: Te6edf3ByteArrayTb4b4b4) Tff7b72= Tffa657Unit

Tff7b72override Tff7b72suspend Tff7b72fun Td2a8ffcloseTb4b4b4(Tb4b4b4) Tb4b4b4{
Te6edf3closeCalled Tff7b72= Tff7b72true
Te6edf3closeCountTff7b72+Tff7b72+
T8b949e// Suspend here until the test releases the gate, holding the restart in-flight.
Te6edf3closeGateTb4b4b4.Te6edf3awaitTb4b4b4(Tb4b4b4)
Te6edf3closeCompletedCountTff7b72+Tff7b72+
Tb4b4b4}
Tb4b4b4}

T8b949e/** Controllable clock β€” tests advance this manually so all time comparisons are deterministic. */
Tff7b72private Tff7b72var Te6edf3clockTb4b4b4: Tffa657Long Tff7b72= T79c0ff0L

T8b949e/**
* Tracks every [SharedRadioInterfaceService] created via [createConnectedService] so [tearDown] can disconnect them
* deterministically. Destroying the process lifecycle does NOT cancel the service's private `_serviceScope` (which
* hosts the heartbeat loop), so we must call `disconnect()` explicitly.
*/
Tff7b72private Tff7b72val Te6edf3services Tff7b72= Te6edf3mutableListOfTff7b72<Te6edf3SharedRadioInterfaceServiceTff7b72>Tb4b4b4(Tb4b4b4)

T8b949e/**
* Tracks the suspended close gate for the in-flight restart test so [tearDown] can release it even if the test body
* throws. Without this, a failed assertion before `closeGate.complete(Unit)` would leave a restart suspended
* forever and hang teardown.
*/
Tff7b72private Tff7b72var Te6edf3activeCloseGateTb4b4b4: Te6edf3CompletableDeferredTff7b72<Tffa657UnitTff7b72>Tff7b72? Tff7b72= Tff7b72null

T8b949e/** Tracks all transports created by the factory so we can count restarts and inspect sent data. */
Tff7b72private Tff7b72val Te6edf3createdTransports Tff7b72= Te6edf3mutableListOfTff7b72<Te6edf3FakeRadioTransportTff7b72>Tb4b4b4(Tb4b4b4)
Tff7b72private Tff7b72val Te6edf3transportFactoryTb4b4b4: Te6edf3RadioTransportFactory Tff7b72= Te6edf3mockTb4b4b4(Te6edf3MockModeTb4b4b4.Te6edf3autofillTb4b4b4)

T8b949e/**
* Creates a [SharedRadioInterfaceService] with a controllable clock and a factory that returns a fresh
* [FakeRadioTransport] per createTransport() call. After construction, calls [connect] then explicitly [onConnect]
* to bring the service to Connected state (FakeRadioTransport does not call onConnect itself).
*
* Pass [transportProvider] to swap in a custom test double (e.g. a suspending-close fake) instead of the default
* [FakeRadioTransport]; the default records each created transport in [createdTransports]. Set [startConnected] to
* false when a test needs to inspect construction or an initial transport-start failure before connection.
*/
Tff7b72private Tff7b72fun Td2a8ffcreateConnectedServiceTb4b4b4(
Te6edf3addressTb4b4b4: Tffa657StringTb4b4b4,
Te6edf3transportProviderTb4b4b4: Tb4b4b4(Tb4b4b4) Tff7b72-Tff7b72> Te6edf3RadioTransport Tff7b72= Tb4b4b4{ Te6edf3FakeRadioTransportTb4b4b4(Tb4b4b4)Tb4b4b4.Te6edf3also Tb4b4b4{ Te6edf3createdTransportsTb4b4b4.Te6edf3addTb4b4b4(Tffa657itTb4b4b4) Tb4b4b4} Tb4b4b4}Tb4b4b4,
Te6edf3networkAvailabilityTb4b4b4: Te6edf3MutableStateFlowTff7b72<Tffa657BooleanTff7b72> Tff7b72= Te6edf3MutableStateFlowTb4b4b4(Tff7b72trueTb4b4b4)Tb4b4b4,
Te6edf3startConnectedTb4b4b4: Tffa657Boolean Tff7b72= Tff7b72trueTb4b4b4,
Tb4b4b4)Tb4b4b4: Te6edf3SharedRadioInterfaceService Tb4b4b4{
Te6edf3every Tb4b4b4{ Te6edf3networkRepositoryTb4b4b4.Te6edf3networkAvailable Tb4b4b4} Te6edf3returns Te6edf3networkAvailability
Te6edf3every Tb4b4b4{ Te6edf3networkRepositoryTb4b4b4.Te6edf3resolvedList Tb4b4b4} Te6edf3returns Te6edf3MutableSharedFlowTb4b4b4(Tb4b4b4)
Te6edf3every Tb4b4b4{ Te6edf3analyticsTb4b4b4.Te6edf3isPlatformServicesAvailable Tb4b4b4} Te6edf3returns Tff7b72false
Te6edf3every Tb4b4b4{ Te6edf3transportFactoryTb4b4b4.Te6edf3supportedDeviceTypes Tb4b4b4} Te6edf3returns Te6edf3listOfTb4b4b4(Te6edf3DeviceTypeTb4b4b4.Te6edf3BLETb4b4b4)
Te6edf3every Tb4b4b4{ Te6edf3transportFactoryTb4b4b4.Te6edf3mockTransportEnabled Tb4b4b4} Te6edf3returns Te6edf3MutableStateFlowTb4b4b4(Tff7b72falseTb4b4b4)
Te6edf3every Tb4b4b4{ Te6edf3transportFactoryTb4b4b4.Te6edf3isAddressValidTb4b4b4(Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4) Tb4b4b4} Te6edf3returns Tff7b72true
Te6edf3every Tb4b4b4{ Te6edf3transportFactoryTb4b4b4.Te6edf3toInterfaceAddressTb4b4b4(Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4, Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4) Tb4b4b4} Te6edf3returns Te6edf3address
Te6edf3every Tb4b4b4{ Te6edf3transportFactoryTb4b4b4.Te6edf3createTransportTb4b4b4(Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4, Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4) Tb4b4b4} Te6edf3calls Tb4b4b4{ Te6edf3transportProviderTb4b4b4(Tb4b4b4) Tb4b4b4}

Te6edf3radioPrefsTb4b4b4.Te6edf3setDevAddrTb4b4b4(Te6edf3addressTb4b4b4)

Tff7b72val Te6edf3service Tff7b72=
Te6edf3SharedRadioInterfaceServiceTb4b4b4(
Te6edf3dispatchers Tff7b72= Te6edf3dispatchersTb4b4b4,
Te6edf3bluetoothRepository Tff7b72= Te6edf3bluetoothRepositoryTb4b4b4,
Te6edf3networkRepository Tff7b72= Te6edf3networkRepositoryTb4b4b4,
Te6edf3serialDevicePresence Tff7b72= Te6edf3serialDevicePresenceTb4b4b4,
Te6edf3processLifecycle Tff7b72= Te6edf3processLifecycleOwnerTb4b4b4.Te6edf3lifecycleTb4b4b4,
Te6edf3radioPrefs Tff7b72= Te6edf3radioPrefsTb4b4b4,
Te6edf3transportFactory Tff7b72= Te6edf3transportFactoryTb4b4b4,
Te6edf3analytics Tff7b72= Te6edf3analyticsTb4b4b4,
Tb4b4b4)
Te6edf3serviceTb4b4b4.Te6edf3clockMillis Tff7b72= Tb4b4b4{ Te6edf3clock Tb4b4b4}
T8b949e// Register the service so tearDown can disconnect it deterministically (the heartbeat loop
T8b949e// launched in _serviceScope would otherwise outlive the test).
Te6edf3servicesTb4b4b4.Te6edf3addTb4b4b4(Te6edf3serviceTb4b4b4)
Tff7b72if Tb4b4b4(Te6edf3startConnectedTb4b4b4) Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3connectTb4b4b4(Tb4b4b4)
Te6edf3serviceTb4b4b4.Te6edf3onConnectTb4b4b4(Tb4b4b4)
Tb4b4b4}
Tff7b72return Te6edf3service
Tb4b4b4}

Tf0883e@Test
Tff7b72fun Td2a8ff`transport factory failure revokes admitted session and retry uses a fresh generation`Tb4b4b4(Tb4b4b4) Tff7b72=
Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Te6edf3bluetoothRepositoryTb4b4b4.Te6edf3setBluetoothEnabledTb4b4b4(Tff7b72falseTb4b4b4)
Tff7b72val Te6edf3networkAvailability Tff7b72= Te6edf3MutableStateFlowTb4b4b4(Tff7b72falseTb4b4b4)
Tff7b72var Te6edf3failCreation Tff7b72= Tff7b72true
Tff7b72var Te6edf3failedSessionCallbackTb4b4b4: Te6edf3RadioInterfaceService? Tff7b72= Tff7b72null
Tff7b72val Te6edf3service Tff7b72=
Te6edf3createConnectedServiceTb4b4b4(
Te6edf3address Tff7b72= Ta5d6ff"Ta5d6fft192.0.2.1Ta5d6ff"Tb4b4b4,
Te6edf3networkAvailability Tff7b72= Te6edf3networkAvailabilityTb4b4b4,
Te6edf3startConnected Tff7b72= Tff7b72falseTb4b4b4,
Tb4b4b4)
Te6edf3every Tb4b4b4{ Te6edf3transportFactoryTb4b4b4.Te6edf3createTransportTb4b4b4(Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4, Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4) Tb4b4b4} Te6edf3calls
Tb4b4b4{
Tff7b72if Tb4b4b4(Te6edf3failCreationTb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3callback Tff7b72= Tffa657itTb4b4b4.Te6edf3argsTff7b72[T79c0ff1Tff7b72] Tff7b72as Te6edf3RadioInterfaceService
Te6edf3failedSessionCallback Tff7b72= Te6edf3callback
Te6edf3callbackTb4b4b4.Te6edf3onConnectTb4b4b4(Tb4b4b4)
Tff7b72throw Te6edf3IllegalStateExceptionTb4b4b4(Ta5d6ff"Ta5d6fftransport factory failedTa5d6ff"Tb4b4b4)
Tb4b4b4}
Te6edf3FakeRadioTransportTb4b4b4(Tb4b4b4)Tb4b4b4.Te6edf3also Tb4b4b4{ Te6edf3createdTransportsTb4b4b4.Te6edf3addTb4b4b4(Tffa657itTb4b4b4) Tb4b4b4}
Tb4b4b4}
Tff7b72try Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3connectTb4b4b4(Tb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)

Te6edf3assertNullTb4b4b4(Te6edf3serviceTb4b4b4.Te6edf3activeSessionTb4b4b4.Te6edf3valueTb4b4b4, Ta5d6ff"Ta5d6ffa failed factory call must revoke the admitted sessionTa5d6ff"Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(T79c0ff1LTb4b4b4, Te6edf3serviceTb4b4b4.Te6edf3sessionGenerationTb4b4b4.Te6edf3valueTb4b4b4, Ta5d6ff"Ta5d6ffthe observed failed generation stays consumedTa5d6ff"Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(
Te6edf3ConnectionStateTb4b4b4.Te6edf3DisconnectedTb4b4b4,
Te6edf3serviceTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3valueTb4b4b4,
Ta5d6ff"Ta5d6ffa synchronous partial callback must not leave the failed transport connectedTa5d6ff"Tb4b4b4,
Tb4b4b4)
Te6edf3assertTrueTb4b4b4(Te6edf3createdTransportsTb4b4b4.Te6edf3isEmptyTb4b4b4(Tb4b4b4)Tb4b4b4, Ta5d6ff"Ta5d6ffa failed factory call must not publish a transportTa5d6ff"Tb4b4b4)

Tff7b72val Te6edf3queuedFrame Tff7b72= Te6edf3asyncTb4b4b4(Te6edf3start Tff7b72= Te6edf3CoroutineStartTb4b4b4.Te6edf3UNDISPATCHEDTb4b4b4) Tb4b4b4{ Te6edf3serviceTb4b4b4.Te6edf3receivedDataTb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4) Tb4b4b4}
Tff7b72try Tb4b4b4{
Tff7b72val Te6edf3revokedCallback Tff7b72= Te6edf3requireNotNullTb4b4b4(Te6edf3failedSessionCallbackTb4b4b4)
Te6edf3revokedCallbackTb4b4b4.Te6edf3onConnectTb4b4b4(Tb4b4b4)
Te6edf3revokedCallbackTb4b4b4.Te6edf3handleFromRadioTb4b4b4(Te6edf3byteArrayOfTb4b4b4(T79c0ff1Tb4b4b4, T79c0ff2Tb4b4b4, T79c0ff3Tb4b4b4)Tb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(
Te6edf3ConnectionStateTb4b4b4.Te6edf3DisconnectedTb4b4b4,
Te6edf3serviceTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3valueTb4b4b4,
Ta5d6ff"Ta5d6ffcallbacks from a revoked factory session must not restore connection stateTa5d6ff"Tb4b4b4,
Tb4b4b4)
Te6edf3assertFalseTb4b4b4(
Te6edf3queuedFrameTb4b4b4.Te6edf3isCompletedTb4b4b4,
Ta5d6ff"Ta5d6ffcallbacks from a revoked factory session must not enqueue dataTa5d6ff"Tb4b4b4,
Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3queuedFrameTb4b4b4.Te6edf3cancelTb4b4b4(Tb4b4b4)
Tb4b4b4}

Te6edf3failCreation Tff7b72= Tff7b72false
Te6edf3serviceTb4b4b4.Te6edf3connectTb4b4b4(Tb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)

Te6edf3assertEqualsTb4b4b4(T79c0ff2LTb4b4b4, Te6edf3serviceTb4b4b4.Te6edf3sessionGenerationTb4b4b4.Te6edf3valueTb4b4b4, Ta5d6ff"Ta5d6ffa retry must receive a strictly newer generationTa5d6ff"Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(T79c0ff2LTb4b4b4, Te6edf3serviceTb4b4b4.Te6edf3activeSessionTb4b4b4.Te6edf3valueTff7b72?.Te6edf3generationTb4b4b4)
Te6edf3assertEqualsTb4b4b4(Ta5d6ff"Ta5d6fft192.0.2.1Ta5d6ff"Tb4b4b4, Te6edf3serviceTb4b4b4.Te6edf3activeSessionTb4b4b4.Te6edf3valueTff7b72?.Te6edf3addressTb4b4b4)
Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4, Ta5d6ff"Ta5d6ffthe successful retry must publish exactly one transportTa5d6ff"Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Tb4b4b4}
Tb4b4b4}

Tf0883e@Test
Tff7b72fun Td2a8ff`setDeviceAddress contains factory failure and same-address repair can retry`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Te6edf3bluetoothRepositoryTb4b4b4.Te6edf3setBluetoothEnabledTb4b4b4(Tff7b72falseTb4b4b4)
Tff7b72val Te6edf3networkAvailability Tff7b72= Te6edf3MutableStateFlowTb4b4b4(Tff7b72falseTb4b4b4)
Tff7b72var Te6edf3failCreation Tff7b72= Tff7b72true
Tff7b72val Te6edf3service Tff7b72=
Te6edf3createConnectedServiceTb4b4b4(
Te6edf3address Tff7b72= Ta5d6ff"Ta5d6fft192.0.2.1Ta5d6ff"Tb4b4b4,
Te6edf3networkAvailability Tff7b72= Te6edf3networkAvailabilityTb4b4b4,
Te6edf3startConnected Tff7b72= Tff7b72falseTb4b4b4,
Tb4b4b4)
Te6edf3every Tb4b4b4{ Te6edf3transportFactoryTb4b4b4.Te6edf3createTransportTb4b4b4(Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4, Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4) Tb4b4b4} Te6edf3calls
Tb4b4b4{
Tff7b72if Tb4b4b4(Te6edf3failCreationTb4b4b4) Tff7b72throw Te6edf3IllegalStateExceptionTb4b4b4(Ta5d6ff"Ta5d6fftransport factory failedTa5d6ff"Tb4b4b4)
Te6edf3FakeRadioTransportTb4b4b4(Tb4b4b4)Tb4b4b4.Te6edf3also Tb4b4b4{ Te6edf3createdTransportsTb4b4b4.Te6edf3addTb4b4b4(Tffa657itTb4b4b4) Tb4b4b4}
Tb4b4b4}
Tff7b72try Tb4b4b4{
Te6edf3assertTrueTb4b4b4(Te6edf3serviceTb4b4b4.Te6edf3setDeviceAddressTb4b4b4(Ta5d6ff"Ta5d6fft192.0.2.2Ta5d6ff"Tb4b4b4)Tb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)

Te6edf3assertNullTb4b4b4(Te6edf3serviceTb4b4b4.Te6edf3activeSessionTb4b4b4.Te6edf3valueTb4b4b4, Ta5d6ff"Ta5d6ffthe failed setDeviceAddress start must revoke its sessionTa5d6ff"Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(T79c0ff1LTb4b4b4, Te6edf3serviceTb4b4b4.Te6edf3sessionGenerationTb4b4b4.Te6edf3valueTb4b4b4, Ta5d6ff"Ta5d6ffthe failed generation remains consumedTa5d6ff"Tb4b4b4)
Te6edf3assertTrueTb4b4b4(Te6edf3createdTransportsTb4b4b4.Te6edf3isEmptyTb4b4b4(Tb4b4b4)Tb4b4b4, Ta5d6ff"Ta5d6ffa failed factory call must not publish a transportTa5d6ff"Tb4b4b4)

Te6edf3failCreation Tff7b72= Tff7b72false
Te6edf3assertTrueTb4b4b4(Te6edf3serviceTb4b4b4.Te6edf3setDeviceAddressTb4b4b4(Ta5d6ff"Ta5d6fft192.0.2.2Ta5d6ff"Tb4b4b4)Tb4b4b4, Ta5d6ff"Ta5d6ffa disconnected same-address selection is a repairTa5d6ff"Tb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)

Te6edf3assertEqualsTb4b4b4(T79c0ff2LTb4b4b4, Te6edf3serviceTb4b4b4.Te6edf3sessionGenerationTb4b4b4.Te6edf3valueTb4b4b4, Ta5d6ff"Ta5d6ffthe repair retry must use a fresh generationTa5d6ff"Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(T79c0ff2LTb4b4b4, Te6edf3serviceTb4b4b4.Te6edf3activeSessionTb4b4b4.Te6edf3valueTff7b72?.Te6edf3generationTb4b4b4)
Te6edf3assertEqualsTb4b4b4(Ta5d6ff"Ta5d6fft192.0.2.2Ta5d6ff"Tb4b4b4, Te6edf3serviceTb4b4b4.Te6edf3activeSessionTb4b4b4.Te6edf3valueTff7b72?.Te6edf3addressTb4b4b4)
Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Tb4b4b4}
Tb4b4b4}

Tf0883e@Test
Tff7b72fun Td2a8ff`ordered session lane serializes handshake work while independent leases remain concurrent`Tb4b4b4(Tb4b4b4) Tff7b72=
Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Ta5d6ff"Ta5d6ffxAA:BB:CC:DD:EE:FFTa5d6ff"Tb4b4b4)
Tff7b72val Te6edf3session Tff7b72= Te6edf3requireNotNullTb4b4b4(Te6edf3serviceTb4b4b4.Te6edf3activeSessionTb4b4b4.Te6edf3valueTb4b4b4)
Tff7b72val Te6edf3firstStarted Tff7b72= Te6edf3CompletableDeferredTff7b72<Tffa657UnitTff7b72>Tb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3releaseFirst Tff7b72= Te6edf3CompletableDeferredTff7b72<Tffa657UnitTff7b72>Tb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3secondStarted Tff7b72= Te6edf3CompletableDeferredTff7b72<Tffa657UnitTff7b72>Tb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3independentStarted Tff7b72= Te6edf3CompletableDeferredTff7b72<Tffa657UnitTff7b72>Tb4b4b4(Tb4b4b4)

Tff7b72val Te6edf3first Tff7b72= Te6edf3launch Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3runWhileSessionActiveTb4b4b4(Te6edf3sessionTb4b4b4) Tb4b4b4{
Te6edf3firstStartedTb4b4b4.Te6edf3completeTb4b4b4(Tffa657UnitTb4b4b4)
Te6edf3releaseFirstTb4b4b4.Te6edf3awaitTb4b4b4(Tb4b4b4)
Tb4b4b4}
Tb4b4b4}
Te6edf3firstStartedTb4b4b4.Te6edf3awaitTb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3second Tff7b72= Te6edf3launch Tb4b4b4{ Te6edf3serviceTb4b4b4.Te6edf3runWhileSessionActiveTb4b4b4(Te6edf3sessionTb4b4b4) Tb4b4b4{ Te6edf3secondStartedTb4b4b4.Te6edf3completeTb4b4b4(Tffa657UnitTb4b4b4) Tb4b4b4} Tb4b4b4}
Tff7b72val Te6edf3independent Tff7b72= Te6edf3launch Tb4b4b4{ Te6edf3serviceTb4b4b4.Te6edf3runWithSessionLeaseTb4b4b4(Te6edf3sessionTb4b4b4) Tb4b4b4{ Te6edf3independentStartedTb4b4b4.Te6edf3completeTb4b4b4(Tffa657UnitTb4b4b4) Tb4b4b4} Tb4b4b4}
Tff7b72try Tb4b4b4{
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)

Te6edf3assertFalseTb4b4b4(Te6edf3secondStartedTb4b4b4.Te6edf3isCompletedTb4b4b4, Ta5d6ff"Ta5d6ffordered work must wait for the current lane ownerTa5d6ff"Tb4b4b4)
Te6edf3assertTrueTb4b4b4(Te6edf3independentStartedTb4b4b4.Te6edf3isCompletedTb4b4b4, Ta5d6ff"Ta5d6ffindependent deferred work must acquire its own leaseTa5d6ff"Tb4b4b4)

Te6edf3releaseFirstTb4b4b4.Te6edf3completeTb4b4b4(Tffa657UnitTb4b4b4)
Te6edf3firstTb4b4b4.Te6edf3joinTb4b4b4(Tb4b4b4)
Te6edf3secondTb4b4b4.Te6edf3joinTb4b4b4(Tb4b4b4)
Te6edf3independentTb4b4b4.Te6edf3joinTb4b4b4(Tb4b4b4)
Te6edf3assertTrueTb4b4b4(Te6edf3secondStartedTb4b4b4.Te6edf3isCompletedTb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3releaseFirstTb4b4b4.Te6edf3completeTb4b4b4(Tffa657UnitTb4b4b4)
Te6edf3firstTb4b4b4.Te6edf3cancelTb4b4b4(Tb4b4b4)
Te6edf3secondTb4b4b4.Te6edf3cancelTb4b4b4(Tb4b4b4)
Te6edf3independentTb4b4b4.Te6edf3cancelTb4b4b4(Tb4b4b4)
Te6edf3firstTb4b4b4.Te6edf3joinTb4b4b4(Tb4b4b4)
Te6edf3secondTb4b4b4.Te6edf3joinTb4b4b4(Tb4b4b4)
Te6edf3independentTb4b4b4.Te6edf3joinTb4b4b4(Tb4b4b4)
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

Tf0883e@Test
Tff7b72fun Td2a8ff`transport teardown remains cancellation safe while draining existing session leases`Tb4b4b4(Tb4b4b4) Tff7b72=
Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Ta5d6ff"Ta5d6ffxAA:BB:CC:DD:EE:FFTa5d6ff"Tb4b4b4)
Tff7b72val Te6edf3session Tff7b72= Te6edf3requireNotNullTb4b4b4(Te6edf3serviceTb4b4b4.Te6edf3activeSessionTb4b4b4.Te6edf3valueTb4b4b4)
Tff7b72val Te6edf3workStarted Tff7b72= Te6edf3CompletableDeferredTff7b72<Tffa657UnitTff7b72>Tb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3releaseWork Tff7b72= Te6edf3CompletableDeferredTff7b72<Tffa657UnitTff7b72>Tb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3leaseStillCurrent Tff7b72= Te6edf3CompletableDeferredTff7b72<Tffa657BooleanTff7b72>Tb4b4b4(Tb4b4b4)

Tff7b72val Te6edf3workJob Tff7b72= Te6edf3launch Tb4b4b4{
Te6edf3assertTrueTb4b4b4(
Te6edf3serviceTb4b4b4.Te6edf3runWithSessionLeaseTb4b4b4(Te6edf3sessionTb4b4b4) Tb4b4b4{ Te6edf3lease Tff7b72-Tff7b72>
Te6edf3workStartedTb4b4b4.Te6edf3completeTb4b4b4(Tffa657UnitTb4b4b4)
Te6edf3releaseWorkTb4b4b4.Te6edf3awaitTb4b4b4(Tb4b4b4)
Te6edf3leaseStillCurrentTb4b4b4.Te6edf3completeTb4b4b4(Te6edf3leaseTb4b4b4.Te6edf3isCurrentTb4b4b4(Tb4b4b4)Tb4b4b4)
Tb4b4b4}Tb4b4b4,
Tb4b4b4)
Tb4b4b4}
Te6edf3workStartedTb4b4b4.Te6edf3awaitTb4b4b4(Tb4b4b4)

Tff7b72val Te6edf3disconnectJob Tff7b72= Te6edf3launch Tb4b4b4{ Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4) Tb4b4b4}
Tff7b72try Tb4b4b4{
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)

Te6edf3assertFalseTb4b4b4(Te6edf3disconnectJobTb4b4b4.Te6edf3isCompletedTb4b4b4, Ta5d6ff"Ta5d6ffdisconnect must wait for the admitted leaseTa5d6ff"Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(
Te6edf3sessionTb4b4b4,
Te6edf3serviceTb4b4b4.Te6edf3activeSessionTb4b4b4.Te6edf3valueTb4b4b4,
Ta5d6ff"Ta5d6ffthe session token remains published until its admitted operation finishesTa5d6ff"Tb4b4b4,
Tb4b4b4)
Te6edf3assertFalseTb4b4b4(Te6edf3serviceTb4b4b4.Te6edf3isSessionActiveTb4b4b4(Te6edf3sessionTb4b4b4)Tb4b4b4, Ta5d6ff"Ta5d6ffteardown must reject new work immediatelyTa5d6ff"Tb4b4b4)
Te6edf3assertFalseTb4b4b4(
Te6edf3serviceTb4b4b4.Te6edf3runWhileSessionActiveTb4b4b4(Te6edf3sessionTb4b4b4) Tb4b4b4{ Te6edf3errorTb4b4b4(Ta5d6ff"Ta5d6fflate operation must not runTa5d6ff"Tb4b4b4) Tb4b4b4}Tb4b4b4,
Ta5d6ff"Ta5d6ffwork queued after admission closes must be rejectedTa5d6ff"Tb4b4b4,
Tb4b4b4)
Te6edf3disconnectJobTb4b4b4.Te6edf3cancelTb4b4b4(Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3releaseWorkTb4b4b4.Te6edf3completeTb4b4b4(Tffa657UnitTb4b4b4)
Te6edf3workJobTb4b4b4.Te6edf3joinTb4b4b4(Tb4b4b4)
Te6edf3assertTrueTb4b4b4(
Te6edf3leaseStillCurrentTb4b4b4.Te6edf3awaitTb4b4b4(Tb4b4b4)Tb4b4b4,
Ta5d6ff"Ta5d6ffan admitted lease must remain authoritative until its transaction-bound work returnsTa5d6ff"Tb4b4b4,
Tb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Te6edf3disconnectJobTb4b4b4.Te6edf3joinTb4b4b4(Tb4b4b4)
Tb4b4b4}

Te6edf3assertNullTb4b4b4(Te6edf3serviceTb4b4b4.Te6edf3activeSessionTb4b4b4.Te6edf3valueTb4b4b4, Ta5d6ff"Ta5d6ffrevocation publishes only after admitted work drainsTa5d6ff"Tb4b4b4)
Te6edf3assertTrueTb4b4b4(Te6edf3createdTransportsTb4b4b4.Te6edf3singleTb4b4b4(Tb4b4b4)Tb4b4b4.Te6edf3closeCalledTb4b4b4, Ta5d6ff"Ta5d6ffcancellation must not strand the revoked transportTa5d6ff"Tb4b4b4)
Tb4b4b4}

T8b949e// ─── BLE: Liveness timeout triggers recovery ───────────────────────────────────────────────

Tf0883e@Test
Tff7b72fun Td2a8ff`BLE liveness timeout closes old transport and creates fresh one`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Te6edf3clock Tff7b72= T79c0ff0L
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Ta5d6ff"Ta5d6ffxAA:BB:CC:DD:EE:FFTa5d6ff"Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4, Ta5d6ff"Ta5d6ffInitial connect should create one transportTa5d6ff"Tb4b4b4)

Te6edf3clock Tff7b72= T79c0ff6T79c0ff5Te6edf3_000L
Te6edf3serviceTb4b4b4.Te6edf3checkLivenessTb4b4b4(Tb4b4b4)
T8b949e// Under UnconfinedTestDispatcher the liveness restart (sendPoliteDisconnect = false) runs
T8b949e// inline during checkLiveness(). runCurrent/advanceTimeBy are belt-and-suspenders; the
T8b949e// real 500ms polite-disconnect delay is covered by the trailing service.disconnect() below.
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Te6edf3assertEqualsTb4b4b4(T79c0ff2Tb4b4b4, Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4, Ta5d6ff"Ta5d6ffLiveness restart should create exactly one fresh transportTa5d6ff"Tb4b4b4)
Te6edf3assertTrueTb4b4b4(Te6edf3createdTransportsTb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4)Tb4b4b4.Te6edf3closeCalledTb4b4b4, Ta5d6ff"Ta5d6ffOld transport must be closedTa5d6ff"Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3createdTransportsTb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4)Tb4b4b4.Te6edf3closeCountTb4b4b4, Ta5d6ff"Ta5d6ffOld transport closed exactly onceTa5d6ff"Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

Tf0883e@Test
Tff7b72fun Td2a8ff`BLE liveness restart contains factory failure and a later connect can retry`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Te6edf3clock Tff7b72= T79c0ff0L
Tff7b72var Te6edf3failRestart Tff7b72= Tff7b72false
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Ta5d6ff"Ta5d6ffxAA:BB:CC:DD:EE:FFTa5d6ff"Tb4b4b4)
Te6edf3every Tb4b4b4{ Te6edf3transportFactoryTb4b4b4.Te6edf3createTransportTb4b4b4(Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4, Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4) Tb4b4b4} Te6edf3calls
Tb4b4b4{
Tff7b72if Tb4b4b4(Te6edf3failRestartTb4b4b4) Tff7b72throw Te6edf3IllegalStateExceptionTb4b4b4(Ta5d6ff"Ta5d6ffrestart factory failedTa5d6ff"Tb4b4b4)
Te6edf3FakeRadioTransportTb4b4b4(Tb4b4b4)Tb4b4b4.Te6edf3also Tb4b4b4{ Te6edf3createdTransportsTb4b4b4.Te6edf3addTb4b4b4(Tffa657itTb4b4b4) Tb4b4b4}
Tb4b4b4}
Tff7b72try Tb4b4b4{
Te6edf3failRestart Tff7b72= Tff7b72true
Te6edf3clock Tff7b72= T79c0ff6T79c0ff5Te6edf3_000L
Te6edf3serviceTb4b4b4.Te6edf3checkLivenessTb4b4b4(Tb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)

Te6edf3assertTrueTb4b4b4(
Te6edf3createdTransportsTb4b4b4.Te6edf3singleTb4b4b4(Tb4b4b4)Tb4b4b4.Te6edf3closeCalledTb4b4b4,
Ta5d6ff"Ta5d6ffthe failed restart must still close the old transportTa5d6ff"Tb4b4b4,
Tb4b4b4)
Te6edf3assertNullTb4b4b4(Te6edf3serviceTb4b4b4.Te6edf3activeSessionTb4b4b4.Te6edf3valueTb4b4b4, Ta5d6ff"Ta5d6ffthe failed replacement session must be revokedTa5d6ff"Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(T79c0ff2LTb4b4b4, Te6edf3serviceTb4b4b4.Te6edf3sessionGenerationTb4b4b4.Te6edf3valueTb4b4b4, Ta5d6ff"Ta5d6ffthe failed replacement generation remains consumedTa5d6ff"Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(Te6edf3ConnectionStateTb4b4b4.Te6edf3DeviceSleepTb4b4b4, Te6edf3serviceTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3valueTb4b4b4)

Te6edf3failRestart Tff7b72= Tff7b72false
Te6edf3serviceTb4b4b4.Te6edf3connectTb4b4b4(Tb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)

Te6edf3assertEqualsTb4b4b4(T79c0ff3LTb4b4b4, Te6edf3serviceTb4b4b4.Te6edf3sessionGenerationTb4b4b4.Te6edf3valueTb4b4b4, Ta5d6ff"Ta5d6ffthe later retry must receive a fresh generationTa5d6ff"Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(T79c0ff3LTb4b4b4, Te6edf3serviceTb4b4b4.Te6edf3activeSessionTb4b4b4.Te6edf3valueTff7b72?.Te6edf3generationTb4b4b4)
Te6edf3assertEqualsTb4b4b4(T79c0ff2Tb4b4b4, Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4, Ta5d6ff"Ta5d6ffthe later retry must publish one replacement transportTa5d6ff"Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

Tf0883e@Test
Tff7b72fun Td2a8ff`BLE liveness restart does not emit permanent Disconnected`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Te6edf3clock Tff7b72= T79c0ff0L
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Ta5d6ff"Ta5d6ffxAA:BB:CC:DD:EE:FFTa5d6ff"Tb4b4b4)

Tff7b72try Tb4b4b4{
T8b949e// Capture all state transitions during the liveness recovery
Tff7b72val Te6edf3stateEmissions Tff7b72= Te6edf3mutableListOfTff7b72<Te6edf3ConnectionStateTff7b72>Tb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3collectJob Tff7b72= Te6edf3backgroundScopeTb4b4b4.Te6edf3launch Tb4b4b4{ Te6edf3serviceTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3collect Tb4b4b4{ Te6edf3stateEmissionsTb4b4b4.Te6edf3addTb4b4b4(Tffa657itTb4b4b4) Tb4b4b4} Tb4b4b4}

Te6edf3clock Tff7b72= T79c0ff6T79c0ff5Te6edf3_000L
Te6edf3serviceTb4b4b4.Te6edf3checkLivenessTb4b4b4(Tb4b4b4)
T8b949e// The restart completes inline under UnconfinedTestDispatcher; runCurrent/advanceTimeBy
T8b949e// are belt-and-suspenders. The trailing disconnect() covers its own 500ms polite delay.
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Te6edf3collectJobTb4b4b4.Te6edf3cancelTb4b4b4(Tb4b4b4)

T8b949e// Recovery must NEVER emit permanent Disconnected
Te6edf3assertFalseTb4b4b4(
Te6edf3ConnectionStateTb4b4b4.Te6edf3Disconnected Tff7b72in Te6edf3stateEmissionsTb4b4b4,
Ta5d6ff"Ta5d6ffAutomatic recovery must not emit permanent Disconnected state Ta5d6ff" Tff7b72+ Ta5d6ff"Ta5d6ff(emitted: Tffd700$Te6edf3stateEmissionsTa5d6ff)Ta5d6ff"Tb4b4b4,
Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

Tf0883e@Test
Tff7b72fun Td2a8ff`BLE liveness restart does not emit user-facing connection error`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Te6edf3clock Tff7b72= T79c0ff0L
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Ta5d6ff"Ta5d6ffxAA:BB:CC:DD:EE:FFTa5d6ff"Tb4b4b4)

Tff7b72try Tb4b4b4{
T8b949e// Collect connectionError emissions β€” automatic liveness recovery must be silent.
T8b949e// _connectionError is a no-replay SharedFlow, so the collector must subscribe before
T8b949e// triggering the liveness timeout. Under UnconfinedTestDispatcher the launch runs
T8b949e// eagerly to its first suspension (awaiting SharedFlow emission).
Tff7b72val Te6edf3errors Tff7b72= Te6edf3mutableListOfTff7b72<Tffa657StringTff7b72>Tb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3collectJob Tff7b72= Te6edf3backgroundScopeTb4b4b4.Te6edf3launch Tb4b4b4{ Te6edf3serviceTb4b4b4.Te6edf3connectionErrorTb4b4b4.Te6edf3collect Tb4b4b4{ Te6edf3errorsTb4b4b4.Te6edf3addTb4b4b4(Tffa657itTb4b4b4) Tb4b4b4} Tb4b4b4}

Te6edf3clock Tff7b72= T79c0ff6T79c0ff5Te6edf3_000L
Te6edf3serviceTb4b4b4.Te6edf3checkLivenessTb4b4b4(Tb4b4b4)
T8b949e// The restart completes inline under UnconfinedTestDispatcher; runCurrent/advanceTimeBy
T8b949e// are belt-and-suspenders (mirrors the sibling liveness tests' pattern).
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Te6edf3collectJobTb4b4b4.Te6edf3cancelTb4b4b4(Tb4b4b4)

Te6edf3assertTrueTb4b4b4(
Te6edf3errorsTb4b4b4.Te6edf3isEmptyTb4b4b4(Tb4b4b4)Tb4b4b4,
Ta5d6ff"Ta5d6ffAutomatic BLE liveness recovery must not emit user-facing connection error (got: Tffd700$Te6edf3errorsTa5d6ff)Ta5d6ff"Tb4b4b4,
Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

Tf0883e@Test
Tff7b72fun Td2a8ff`BLE liveness restart does not send polite disconnect into zombie transport`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Te6edf3clock Tff7b72= T79c0ff0L
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Ta5d6ff"Ta5d6ffxAA:BB:CC:DD:EE:FFTa5d6ff"Tb4b4b4)
Tff7b72try Tb4b4b4{
Tff7b72val Te6edf3oldTransport Tff7b72= Te6edf3createdTransportsTb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4)

Te6edf3oldTransportTb4b4b4.Te6edf3sentDataTb4b4b4.Te6edf3clearTb4b4b4(Tb4b4b4)

Te6edf3clock Tff7b72= T79c0ff6T79c0ff5Te6edf3_000L
Te6edf3serviceTb4b4b4.Te6edf3checkLivenessTb4b4b4(Tb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Te6edf3assertTrueTb4b4b4(
Te6edf3oldTransportTb4b4b4.Te6edf3sentDataTb4b4b4.Te6edf3isEmptyTb4b4b4(Tb4b4b4)Tb4b4b4,
Ta5d6ff"Ta5d6ffPolite disconnect frame must NOT be sent into zombie transport during liveness restartTa5d6ff"Tb4b4b4,
Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

Tf0883e@Test
Tff7b72fun Td2a8ff`BLE repeated liveness checks do not stack restarts`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Te6edf3clock Tff7b72= T79c0ff0L
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Ta5d6ff"Ta5d6ffxAA:BB:CC:DD:EE:FFTa5d6ff"Tb4b4b4)

Tff7b72try Tb4b4b4{
Te6edf3clock Tff7b72= T79c0ff6T79c0ff5Te6edf3_000L
Te6edf3serviceTb4b4b4.Te6edf3checkLivenessTb4b4b4(Tb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Te6edf3clock Tff7b72= T79c0ff6T79c0ff6Te6edf3_000L
Te6edf3serviceTb4b4b4.Te6edf3checkLivenessTb4b4b4(Tb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Tff7b72val Te6edf3firstTransportCloses Tff7b72= Te6edf3createdTransportsTb4b4b4.Te6edf3firstOrNullTb4b4b4(Tb4b4b4)Tff7b72?.Te6edf3closeCount Tff7b72?: T79c0ff0
Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3firstTransportClosesTb4b4b4, Ta5d6ff"Ta5d6ffFirst transport should be closed exactly once (no stacking)Ta5d6ff"Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

Tf0883e@Test
Tff7b72fun Td2a8ff`BLE in-flight liveness restart prevents overlapping restart via isRestarting`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
T8b949e// Deterministic in-flight overlap: a GatedFakeRadioTransport holds the first restart
T8b949e// genuinely suspended inside stopTransportLocked β†’ close() (awaiting closeGate). This
T8b949e// removes reliance on UnconfinedTestDispatcher scheduling so the overlap window is real.
T8b949e//
T8b949e// The first checkLiveness() flips state to DeviceSleep and CAS-sets isRestarting before
T8b949e// launching the restart coroutine, which then suspends in close(). The second
T8b949e// checkLiveness() is issued while that restart is still suspended and must NOT begin
T8b949e// another close/create cycle.
Tff7b72val Te6edf3gatedTransports Tff7b72= Te6edf3mutableListOfTff7b72<Te6edf3GatedFakeRadioTransportTff7b72>Tb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3closeGate Tff7b72= Te6edf3CompletableDeferredTff7b72<Tffa657UnitTff7b72>Tb4b4b4(Tb4b4b4)
T8b949e// Publish the gate to activeCloseGate so tearDown can release it even if an assertion below
T8b949e// throws before we reach the try/finally β€” otherwise disconnect() would hang on close().
Te6edf3activeCloseGate Tff7b72= Te6edf3closeGate
Tff7b72val Te6edf3transportProviderTb4b4b4: Tb4b4b4(Tb4b4b4) Tff7b72-Tff7b72> Te6edf3RadioTransport Tff7b72= Tb4b4b4{
Te6edf3GatedFakeRadioTransportTb4b4b4(Te6edf3closeGateTb4b4b4)Tb4b4b4.Te6edf3also Tb4b4b4{ Te6edf3gatedTransportsTb4b4b4.Te6edf3addTb4b4b4(Tffa657itTb4b4b4) Tb4b4b4}
Tb4b4b4}

Te6edf3clock Tff7b72= T79c0ff0L
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Ta5d6ff"Ta5d6ffxAA:BB:CC:DD:EE:FFTa5d6ff"Tb4b4b4, Te6edf3transportProviderTb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3gatedTransportsTb4b4b4.Te6edf3sizeTb4b4b4, Ta5d6ff"Ta5d6ffInitial connect should create one transportTa5d6ff"Tb4b4b4)
Tff7b72val Te6edf3initialTransport Tff7b72= Te6edf3gatedTransportsTb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4)

T8b949e// Past the 60s threshold β†’ first checkLiveness triggers a restart whose close() suspends
T8b949e// on closeGate. Under UnconfinedTestDispatcher the launched restart runs eagerly up to the
T8b949e// suspension point, so by the time checkLiveness() returns the restart is in-flight.
Te6edf3clock Tff7b72= T79c0ff6T79c0ff5Te6edf3_000L
Te6edf3serviceTb4b4b4.Te6edf3checkLivenessTb4b4b4(Tb4b4b4)

T8b949e// Issue a second checkLiveness() while the first restart is still suspended in close().
T8b949e// Do NOT advance time here β€” the overlap must happen with the first restart in-flight.
Te6edf3clock Tff7b72= T79c0ff6T79c0ff5Te6edf3_001L
Te6edf3serviceTb4b4b4.Te6edf3checkLivenessTb4b4b4(Tb4b4b4)

T8b949e// Assertions below run while the restart is held suspended on closeGate. They MUST be
T8b949e// wrapped in try/finally so closeGate is completed even if one of them fails; otherwise
T8b949e// tearDown's runBlocking { disconnect() } would hang forever on the gated close().
Tff7b72try Tb4b4b4{
T8b949e// While the first restart is suspended: exactly one transport created so far, and close()
T8b949e// was entered exactly once and has NOT completed. The second check started no new cycle.
Te6edf3assertEqualsTb4b4b4(
T79c0ff1Tb4b4b4,
Te6edf3gatedTransportsTb4b4b4.Te6edf3sizeTb4b4b4,
Ta5d6ff"Ta5d6ffSecond check must not create a transport while the first restart is in-flightTa5d6ff"Tb4b4b4,
Tb4b4b4)
Te6edf3assertTrueTb4b4b4(
Te6edf3initialTransportTb4b4b4.Te6edf3closeCalledTb4b4b4,
Ta5d6ff"Ta5d6ffFirst transport close must have been entered by the restartTa5d6ff"Tb4b4b4,
Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(
T79c0ff1Tb4b4b4,
Te6edf3initialTransportTb4b4b4.Te6edf3closeCountTb4b4b4,
Ta5d6ff"Ta5d6ffclose() entered exactly once (no stacking of close calls)Ta5d6ff"Tb4b4b4,
Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(
T79c0ff0Tb4b4b4,
Te6edf3initialTransportTb4b4b4.Te6edf3closeCompletedCountTb4b4b4,
Ta5d6ff"Ta5d6ffclose() must still be suspended (restart held in-flight) before releasing the gateTa5d6ff"Tb4b4b4,
Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
T8b949e// Release the gate unconditionally so the suspended restart can complete. tearDown
T8b949e// also releases activeCloseGate, but completing it here is required for the post-finally
T8b949e// assertions below to observe the resumed restart.
Te6edf3closeGateTb4b4b4.Te6edf3completeTb4b4b4(Tffa657UnitTb4b4b4)
Tb4b4b4}

T8b949e// Release the gate: the suspended restart resumes, completes stopTransportLocked (whose
T8b949e// polite-disconnect delay is 500ms β€” covered by the 1s below), and startTransportLocked
T8b949e// creates the single fresh transport. isRestarting is reset in the finally block.
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

T8b949e// Exactly 2 transports: 1 initial + 1 restart. A stacking bug would produce 3+.
Te6edf3assertEqualsTb4b4b4(
T79c0ff2Tb4b4b4,
Te6edf3gatedTransportsTb4b4b4.Te6edf3sizeTb4b4b4,
Ta5d6ff"Ta5d6ffExactly one fresh transport created after the restart resumes (1 initial + 1 restart)Ta5d6ff"Tb4b4b4,
Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(
T79c0ff1Tb4b4b4,
Te6edf3initialTransportTb4b4b4.Te6edf3closeCountTb4b4b4,
Ta5d6ff"Ta5d6ffFirst transport still closed exactly once after restart completesTa5d6ff"Tb4b4b4,
Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3initialTransportTb4b4b4.Te6edf3closeCompletedCountTb4b4b4, Ta5d6ff"Ta5d6ffFirst transport close completed exactly onceTa5d6ff"Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e// ─── Non-BLE: Liveness does not mutate state ───────────────────────────────────────────────

Tf0883e@Test
Tff7b72fun Td2a8ff`non-BLE transport liveness timeout does not close transport or change state`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Te6edf3clock Tff7b72= T79c0ff0L
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Ta5d6ff"Ta5d6fft192.168.1.100Ta5d6ff"Tb4b4b4)
Tff7b72try Tb4b4b4{
Tff7b72val Te6edf3stateBefore Tff7b72= Te6edf3serviceTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3value

Te6edf3clock Tff7b72= T79c0ff6T79c0ff5Te6edf3_000L
Te6edf3serviceTb4b4b4.Te6edf3checkLivenessTb4b4b4(Tb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Te6edf3assertEqualsTb4b4b4(Te6edf3stateBeforeTb4b4b4, Te6edf3serviceTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3valueTb4b4b4, Ta5d6ff"Ta5d6ffNon-BLE state must not changeTa5d6ff"Tb4b4b4)
Te6edf3assertFalseTb4b4b4(Te6edf3createdTransportsTb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4)Tb4b4b4.Te6edf3closeCalledTb4b4b4, Ta5d6ff"Ta5d6ffNon-BLE transport must NOT be closedTa5d6ff"Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4, Ta5d6ff"Ta5d6ffNo restart should occur for non-BLE transportTa5d6ff"Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e// ─── handleFromRadio resets the liveness timer ──────────────────────────────────────────────

Tf0883e@Test
Tff7b72fun Td2a8ff`inbound data resets liveness timer so timeout does not fire`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Te6edf3clock Tff7b72= T79c0ff0L
Tff7b72val Te6edf3address Tff7b72= Ta5d6ff"Ta5d6ffxAA:BB:CC:DD:EE:FFTa5d6ff"
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Te6edf3addressTb4b4b4)

Tff7b72try Tb4b4b4{
T8b949e// Advance 30s, then receive data (resets lastDataReceivedMillis to clock=30s)
Te6edf3clock Tff7b72= T79c0ff3T79c0ff0Te6edf3_000L
Tff7b72val Te6edf3payload Tff7b72= Te6edf3byteArrayOfTb4b4b4(T79c0ff1Tb4b4b4, T79c0ff2Tb4b4b4, T79c0ff3Tb4b4b4)
Tff7b72val Te6edf3received Tff7b72= Te6edf3asyncTb4b4b4(Te6edf3start Tff7b72= Te6edf3CoroutineStartTb4b4b4.Te6edf3UNDISPATCHEDTb4b4b4) Tb4b4b4{ Te6edf3serviceTb4b4b4.Te6edf3receivedDataTb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4) Tb4b4b4}
Te6edf3serviceTb4b4b4.Te6edf3handleFromRadioTb4b4b4(Te6edf3payloadTb4b4b4)
Tff7b72val Te6edf3frame Tff7b72= Te6edf3receivedTb4b4b4.Te6edf3awaitTb4b4b4(Tb4b4b4)

Te6edf3assertEqualsTb4b4b4(Te6edf3addressTb4b4b4, Te6edf3frameTb4b4b4.Te6edf3sessionTb4b4b4.Te6edf3addressTb4b4b4)
Te6edf3assertEqualsTb4b4b4(Te6edf3serviceTb4b4b4.Te6edf3sessionGenerationTb4b4b4.Te6edf3valueTb4b4b4, Te6edf3frameTb4b4b4.Te6edf3sessionTb4b4b4.Te6edf3generationTb4b4b4)
Te6edf3assertContentEqualsTb4b4b4(Te6edf3payloadTb4b4b4, Te6edf3frameTb4b4b4.Te6edf3payloadTb4b4b4.Te6edf3toByteArrayTb4b4b4(Tb4b4b4)Tb4b4b4)

T8b949e// 30s since last data β†’ within 60s threshold β†’ should NOT fire
Te6edf3clock Tff7b72= T79c0ff6T79c0ff0Te6edf3_000L
Te6edf3serviceTb4b4b4.Te6edf3checkLivenessTb4b4b4(Tb4b4b4)
Te6edf3assertFalseTb4b4b4(
Te6edf3createdTransportsTb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4)Tb4b4b4.Te6edf3closeCalledTb4b4b4,
Ta5d6ff"Ta5d6ffLiveness must not fire when silence is within threshold after inbound dataTa5d6ff"Tb4b4b4,
Tb4b4b4)

T8b949e// 66s since last data (at t=30s) β†’ past 60s threshold β†’ should fire
Te6edf3clock Tff7b72= T79c0ff9T79c0ff6Te6edf3_000L
Te6edf3serviceTb4b4b4.Te6edf3checkLivenessTb4b4b4(Tb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Te6edf3assertTrueTb4b4b4(
Te6edf3createdTransportsTb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4)Tb4b4b4.Te6edf3closeCalledTb4b4b4,
Ta5d6ff"Ta5d6ffLiveness should fire after silence exceeds threshold since last inbound dataTa5d6ff"Tb4b4b4,
Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e/**
* Regression for the field wedge behind "app shows Connected but the node stops updating": frames dropped by a full
* receive queue must NOT feed the liveness timer. Before the fix, [SharedRadioInterfaceService] stamped
* `lastDataReceivedMillis` on arrival (even for dropped frames), so a wedged consumer kept liveness satisfied
* forever while every frame was discarded.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`frames dropped by a full receive queue do not reset the liveness timer`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Te6edf3clock Tff7b72= T79c0ff0L
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Ta5d6ff"Ta5d6ffxAA:BB:CC:DD:EE:FFTa5d6ff"Tb4b4b4)
Tff7b72try Tb4b4b4{
T8b949e// No collector attached: fill the channel to capacity at t=0. All of these are admitted
T8b949e// and stamp liveness at 0.
Tff7b72val Te6edf3payload Tff7b72= Te6edf3byteArrayOfTb4b4b4(T79c0ff1Tb4b4b4)
Te6edf3repeatTb4b4b4(Te6edf3SharedRadioInterfaceServiceTb4b4b4.Te6edf3RECEIVE_QUEUE_CAPACITYTb4b4b4) Tb4b4b4{ Te6edf3serviceTb4b4b4.Te6edf3handleFromRadioTb4b4b4(Te6edf3payloadTb4b4b4) Tb4b4b4}

T8b949e// A frame arriving at t=30s is DROPPED (queue full). It must not count as liveness data.
Te6edf3clock Tff7b72= T79c0ff3T79c0ff0Te6edf3_000L
Te6edf3serviceTb4b4b4.Te6edf3handleFromRadioTb4b4b4(Te6edf3payloadTb4b4b4)

T8b949e// At t=65s the silence is 65s if the drop was correctly ignored (fires), but only 35s if
T8b949e// the drop stamped the timer (must not happen).
Te6edf3clock Tff7b72= T79c0ff6T79c0ff5Te6edf3_000L
Te6edf3serviceTb4b4b4.Te6edf3checkLivenessTb4b4b4(Tb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Te6edf3assertTrueTb4b4b4(
Te6edf3createdTransportsTb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4)Tb4b4b4.Te6edf3closeCalledTb4b4b4,
Ta5d6ff"Ta5d6ffLiveness must fire on queue-full silence β€” dropped frames must not feed the timerTa5d6ff"Tb4b4b4,
Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e// ─── Session handler timeout: the pipeline must not wedge forever ───────────────────────────

T8b949e/**
* Regression for the 2.8.0 stale-connection wedge: a handler that suspends indefinitely inside
* [SharedRadioInterfaceService.runWhileSessionActive] holds the session-operation lane (the whole inbound
* pipeline). It must be cancelled at the handler timeout so queued work behind it can run.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`wedged session handler is cancelled at the timeout and the pipeline continues`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Te6edf3clock Tff7b72= T79c0ff0L
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Ta5d6ff"Ta5d6ffxAA:BB:CC:DD:EE:FFTa5d6ff"Tb4b4b4)
Tff7b72val Te6edf3session Tff7b72= Te6edf3requireNotNullTb4b4b4(Te6edf3serviceTb4b4b4.Te6edf3activeSessionTb4b4b4.Te6edf3valueTb4b4b4)
Tff7b72val Te6edf3wedgeStarted Tff7b72= Te6edf3CompletableDeferredTff7b72<Tffa657UnitTff7b72>Tb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3neverReleased Tff7b72= Te6edf3CompletableDeferredTff7b72<Tffa657UnitTff7b72>Tb4b4b4(Tb4b4b4)
Tff7b72var Te6edf3wedgeRanToCompletion Tff7b72= Tff7b72false

Tff7b72val Te6edf3wedged Tff7b72= Te6edf3launch Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3runWhileSessionActiveTb4b4b4(Te6edf3sessionTb4b4b4) Tb4b4b4{
Te6edf3wedgeStartedTb4b4b4.Te6edf3completeTb4b4b4(Tffa657UnitTb4b4b4)
Te6edf3neverReleasedTb4b4b4.Te6edf3awaitTb4b4b4(Tb4b4b4) T8b949e// simulates a handler stuck on an unbounded suspension
Te6edf3wedgeRanToCompletion Tff7b72= Tff7b72true
Tb4b4b4}
Tb4b4b4}
Te6edf3wedgeStartedTb4b4b4.Te6edf3awaitTb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3nextStarted Tff7b72= Te6edf3CompletableDeferredTff7b72<Tffa657UnitTff7b72>Tb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3next Tff7b72= Te6edf3launch Tb4b4b4{ Te6edf3serviceTb4b4b4.Te6edf3runWhileSessionActiveTb4b4b4(Te6edf3sessionTb4b4b4) Tb4b4b4{ Te6edf3nextStartedTb4b4b4.Te6edf3completeTb4b4b4(Tffa657UnitTb4b4b4) Tb4b4b4} Tb4b4b4}
Tff7b72try Tb4b4b4{
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3assertFalseTb4b4b4(Te6edf3nextStartedTb4b4b4.Te6edf3isCompletedTb4b4b4, Ta5d6ff"Ta5d6ffordered work is serialized behind the wedged handlerTa5d6ff"Tb4b4b4)

T8b949e// Cross the 2-minute handler timeout: the wedged block is cancelled, releasing the lane.
Te6edf3advanceTimeByTb4b4b4(T79c0ff1T79c0ff2T79c0ff1Te6edf3_000LTb4b4b4)
Te6edf3wedgedTb4b4b4.Te6edf3joinTb4b4b4(Tb4b4b4)
Te6edf3nextTb4b4b4.Te6edf3joinTb4b4b4(Tb4b4b4)

Te6edf3assertFalseTb4b4b4(Te6edf3wedgeRanToCompletionTb4b4b4, Ta5d6ff"Ta5d6ffthe wedged handler must have been cancelled, not completedTa5d6ff"Tb4b4b4)
Te6edf3assertTrueTb4b4b4(Te6edf3nextStartedTb4b4b4.Te6edf3isCompletedTb4b4b4, Ta5d6ff"Ta5d6ffthe handler timeout must release the pipeline for queued workTa5d6ff"Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3neverReleasedTb4b4b4.Te6edf3completeTb4b4b4(Tffa657UnitTb4b4b4)
Te6edf3wedgedTb4b4b4.Te6edf3cancelTb4b4b4(Tb4b4b4)
Te6edf3nextTb4b4b4.Te6edf3cancelTb4b4b4(Tb4b4b4)
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e/**
* The user-facing half of the same wedge: disconnect() drains admitted leases before teardown, so a handler stuck
* forever previously made disconnect unreachable (only a force-stop recovered). The handler timeout must bound that
* wait.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`disconnect completes after a wedged handler is timed out`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Te6edf3clock Tff7b72= T79c0ff0L
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Ta5d6ff"Ta5d6ffxAA:BB:CC:DD:EE:FFTa5d6ff"Tb4b4b4)
Tff7b72val Te6edf3session Tff7b72= Te6edf3requireNotNullTb4b4b4(Te6edf3serviceTb4b4b4.Te6edf3activeSessionTb4b4b4.Te6edf3valueTb4b4b4)
Tff7b72val Te6edf3wedgeStarted Tff7b72= Te6edf3CompletableDeferredTff7b72<Tffa657UnitTff7b72>Tb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3neverReleased Tff7b72= Te6edf3CompletableDeferredTff7b72<Tffa657UnitTff7b72>Tb4b4b4(Tb4b4b4)

Tff7b72val Te6edf3wedged Tff7b72= Te6edf3launch Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3runWhileSessionActiveTb4b4b4(Te6edf3sessionTb4b4b4) Tb4b4b4{
Te6edf3wedgeStartedTb4b4b4.Te6edf3completeTb4b4b4(Tffa657UnitTb4b4b4)
Te6edf3neverReleasedTb4b4b4.Te6edf3awaitTb4b4b4(Tb4b4b4)
Tb4b4b4}
Tb4b4b4}
Te6edf3wedgeStartedTb4b4b4.Te6edf3awaitTb4b4b4(Tb4b4b4)

Tff7b72val Te6edf3disconnectJob Tff7b72= Te6edf3launch Tb4b4b4{ Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4) Tb4b4b4}
Tff7b72try Tb4b4b4{
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3assertFalseTb4b4b4(Te6edf3disconnectJobTb4b4b4.Te6edf3isCompletedTb4b4b4, Ta5d6ff"Ta5d6ffdisconnect must wait while the lease is admittedTa5d6ff"Tb4b4b4)

T8b949e// Cross the handler timeout (cancels the wedged block, draining the lease) plus the
T8b949e// polite-disconnect drain window inside stopTransportLocked.
Te6edf3advanceTimeByTb4b4b4(T79c0ff1T79c0ff2T79c0ff1Te6edf3_000LTb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Te6edf3disconnectJobTb4b4b4.Te6edf3joinTb4b4b4(Tb4b4b4)
Te6edf3wedgedTb4b4b4.Te6edf3joinTb4b4b4(Tb4b4b4)

Te6edf3assertNullTb4b4b4(Te6edf3serviceTb4b4b4.Te6edf3activeSessionTb4b4b4.Te6edf3valueTb4b4b4, Ta5d6ff"Ta5d6ffteardown must complete once the wedged lease is releasedTa5d6ff"Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3neverReleasedTb4b4b4.Te6edf3completeTb4b4b4(Tffa657UnitTb4b4b4)
Te6edf3wedgedTb4b4b4.Te6edf3cancelTb4b4b4(Tb4b4b4)
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

Tf0883e@Test
Tff7b72fun Td2a8ff`USB permission denial emits error and permanent disconnected state`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Te6edf3clock Tff7b72= T79c0ff0L
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Ta5d6ff"Ta5d6ffs/dev/bus/usb/001/002Ta5d6ff"Tb4b4b4)
Tff7b72try Tb4b4b4{
Tff7b72val Te6edf3errors Tff7b72= Te6edf3mutableListOfTff7b72<Tffa657StringTff7b72>Tb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3collectJob Tff7b72= Te6edf3backgroundScopeTb4b4b4.Te6edf3launch Tb4b4b4{ Te6edf3serviceTb4b4b4.Te6edf3connectionErrorTb4b4b4.Te6edf3collect Tb4b4b4{ Te6edf3errorsTb4b4b4.Te6edf3addTb4b4b4(Tffa657itTb4b4b4) Tb4b4b4} Tb4b4b4}

Te6edf3serviceTb4b4b4.Te6edf3onDisconnectTb4b4b4(
Te6edf3isPermanent Tff7b72= Tff7b72trueTb4b4b4,
Te6edf3errorMessage Tff7b72= Tff7b72nullTb4b4b4,
Te6edf3reason Tff7b72= Te6edf3TransportDisconnectReasonTb4b4b4.Te6edf3UsbPermissionDeniedTb4b4b4,
Tb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Te6edf3collectJobTb4b4b4.Te6edf3cancelTb4b4b4(Tb4b4b4)

Te6edf3assertEqualsTb4b4b4(Te6edf3ConnectionStateTb4b4b4.Te6edf3DisconnectedTb4b4b4, Te6edf3serviceTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3valueTb4b4b4)
Te6edf3assertEqualsTb4b4b4(
Te6edf3listOfTb4b4b4(Ta5d6ff"Ta5d6ffUSB permission denied. Reconnect the device to try again.Ta5d6ff"Tb4b4b4)Tb4b4b4,
Te6edf3errorsTb4b4b4,
Ta5d6ff"Ta5d6ffUSB permission denial must surface a specific error message.Ta5d6ff"Tb4b4b4,
Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

Tf0883e@Test
Tff7b72fun Td2a8ff`BLE liveness does not fire when connection state is not Connected`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Te6edf3clock Tff7b72= T79c0ff0L
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Ta5d6ff"Ta5d6ffxAA:BB:CC:DD:EE:FFTa5d6ff"Tb4b4b4)

Tff7b72try Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3onDisconnectTb4b4b4(Te6edf3isPermanent Tff7b72= Tff7b72trueTb4b4b4)
Te6edf3assertFalseTb4b4b4(Te6edf3serviceTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3value Tff7b72=Tff7b72= Te6edf3ConnectionStateTb4b4b4.Te6edf3ConnectedTb4b4b4)

Te6edf3clock Tff7b72= T79c0ff6T79c0ff5Te6edf3_000L
Te6edf3serviceTb4b4b4.Te6edf3checkLivenessTb4b4b4(Tb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Te6edf3assertFalseTb4b4b4(Te6edf3createdTransportsTb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4)Tb4b4b4.Te6edf3closeCalledTb4b4b4, Ta5d6ff"Ta5d6ffLiveness must not fire when not ConnectedTa5d6ff"Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e// ─── connectionRequested gate: environmental recovery vs explicit disconnect ────────────

T8b949e/**
* Regression: after an explicit [SharedRadioInterfaceService.disconnect], BT state emissions MUST NOT restart the
* transport. Without the `connectionRequested` gate, a subsequent `bluetoothRepository.state = enabled` emission
* would silently resurrect the transport the user tore down β€” leaving the app "connected" with no orchestrator
* collector, no NodeDB load, and no channels. Only [disconnect] clears the gate; the state listener checks it
* before calling `startTransportLocked()`.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`BLE state recovery does not restart transport after explicit disconnect`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Te6edf3clock Tff7b72= T79c0ff0L
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Ta5d6ff"Ta5d6ffxAA:BB:CC:DD:EE:FFTa5d6ff"Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4, Ta5d6ff"Ta5d6ffInitial connect should create one transportTa5d6ff"Tb4b4b4)

T8b949e// Explicit user-initiated disconnect: clears the connectionRequested gate BEFORE
T8b949e// stopTransportLocked() so a racing state-listener emission cannot re-arm the transport.
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
T8b949e// Drain the polite-disconnect frame (production waits POLITE_DISCONNECT_DRAIN_MS = 500ms).
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Tff7b72val Te6edf3transportCountAfterDisconnect Tff7b72= Te6edf3createdTransportsTb4b4b4.Te6edf3size

T8b949e// Force a fresh BLE state cycle (disabled β†’ enabled). The initial listener subscription
T8b949e// already consumed the default enabled=true emission during connect(), so toggling is
T8b949e// required to deliver a NEW enabled=true emission that would trigger startTransportLocked().
Te6edf3bluetoothRepositoryTb4b4b4.Te6edf3setBluetoothEnabledTb4b4b4(Tff7b72falseTb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3bluetoothRepositoryTb4b4b4.Te6edf3setBluetoothEnabledTb4b4b4(Tff7b72trueTb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Te6edf3assertEqualsTb4b4b4(
Te6edf3transportCountAfterDisconnectTb4b4b4,
Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4,
Ta5d6ff"Ta5d6ffBT-enabled emission after disconnect must NOT restart transport (connectionRequested gate)Ta5d6ff"Tb4b4b4,
Tb4b4b4)
Te6edf3assertFalseTb4b4b4(
Te6edf3serviceTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3value Tff7b72=Tff7b72= Te6edf3ConnectionStateTb4b4b4.Te6edf3ConnectedTb4b4b4,
Ta5d6ff"Ta5d6ffState must remain Disconnected after post-disconnect BT recovery emissionTa5d6ff"Tb4b4b4,
Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e/**
* Counterpart to the disconnect-gate test: environmental recovery MUST still function while a connection is
* explicitly desired (connectionRequested == true). When the BT radio toggles off then back on (user toggled
* airplane mode, BT permission revoked/restored, etc.) the state listener must tear down and restart the transport.
* Only [disconnect] clears the gate; environmental stops via `stopTransportLocked()` do not.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`BLE environmental recovery restarts transport while connection is still desired`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Te6edf3clock Tff7b72= T79c0ff0L
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Ta5d6ff"Ta5d6ffxAA:BB:CC:DD:EE:FFTa5d6ff"Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4, Ta5d6ff"Ta5d6ffInitial connect should create one transportTa5d6ff"Tb4b4b4)

T8b949e// Environmental stop: BT radio disabled while a connection is active.
T8b949e// stopTransportLocked() fires but connectionRequested stays true (only disconnect() clears it).
Te6edf3bluetoothRepositoryTb4b4b4.Te6edf3setBluetoothEnabledTb4b4b4(Tff7b72falseTb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
T8b949e// Drain the polite-disconnect frame inside the listener's stopTransportLocked() (500ms).
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Te6edf3assertTrueTb4b4b4(
Te6edf3createdTransportsTb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4)Tb4b4b4.Te6edf3closeCalledTb4b4b4,
Ta5d6ff"Ta5d6ffBLE-disabled should close the running transport via environmental stopTa5d6ff"Tb4b4b4,
Tb4b4b4)

T8b949e// Environmental recovery: BT radio re-enabled. connectionRequested is still true, so the
T8b949e// listener MUST call startTransportLocked() and bring the transport back.
Te6edf3bluetoothRepositoryTb4b4b4.Te6edf3setBluetoothEnabledTb4b4b4(Tff7b72trueTb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Te6edf3assertEqualsTb4b4b4(
T79c0ff2Tb4b4b4,
Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4,
Ta5d6ff"Ta5d6ffBLE-enabled should restart transport via environmental recovery (connectionRequested still true)Ta5d6ff"Tb4b4b4,
Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e/**
* Network/TCP counterpart to `BLE state recovery does not restart transport after explicit disconnect`.
*
* The [networkRepository.networkAvailable] listener (see `initStateListeners`) consults the same
* `connectionRequested` gate as the BLE listener: after an explicit [SharedRadioInterfaceService.disconnect], a
* network-available emission MUST NOT resurrect the transport. Without the gate, a connectivity cycle (Wi-Fi
* toggled off→on, network handoff) would silently restart a transport the user tore down.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`network available recovery does not restart transport after explicit disconnect`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Te6edf3clock Tff7b72= T79c0ff0L
Tff7b72val Te6edf3networkAvailability Tff7b72= Te6edf3MutableStateFlowTff7b72<Tffa657BooleanTff7b72>Tb4b4b4(Tff7b72trueTb4b4b4)
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Ta5d6ff"Ta5d6fft192.168.1.100Ta5d6ff"Tb4b4b4, Te6edf3networkAvailability Tff7b72= Te6edf3networkAvailabilityTb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4, Ta5d6ff"Ta5d6ffInitial connect should create one transportTa5d6ff"Tb4b4b4)

T8b949e// Explicit user-initiated disconnect: clears the connectionRequested gate BEFORE
T8b949e// stopTransportLocked() so a racing network-listener emission cannot re-arm the transport.
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
T8b949e// Drain the polite-disconnect frame (production waits POLITE_DISCONNECT_DRAIN_MS = 500ms).
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Tff7b72val Te6edf3transportCountAfterDisconnect Tff7b72= Te6edf3createdTransportsTb4b4b4.Te6edf3size

T8b949e// Force a fresh network-available cycle (false β†’ true). The initial listener subscription
T8b949e// already consumed the default true emission during connect(), so toggling is required to
T8b949e// deliver a NEW true emission that would trigger startTransportLocked().
Te6edf3networkAvailabilityTb4b4b4.Te6edf3value Tff7b72= Tff7b72false
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3networkAvailabilityTb4b4b4.Te6edf3value Tff7b72= Tff7b72true
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Te6edf3assertEqualsTb4b4b4(
Te6edf3transportCountAfterDisconnectTb4b4b4,
Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4,
Ta5d6ff"Ta5d6ffnetwork-available emission after disconnect must NOT restart transport (connectionRequested gate)Ta5d6ff"Tb4b4b4,
Tb4b4b4)
Te6edf3assertFalseTb4b4b4(
Te6edf3serviceTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3value Tff7b72=Tff7b72= Te6edf3ConnectionStateTb4b4b4.Te6edf3ConnectedTb4b4b4,
Ta5d6ff"Ta5d6ffState must remain Disconnected after post-disconnect network recovery emissionTa5d6ff"Tb4b4b4,
Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e// ─── connectionRequested gate: setDeviceAddress(null)/("n") deselect ────────────────────

T8b949e/**
* Regression: after [SharedRadioInterfaceService.setDeviceAddress] with `null`/`"n"` (deselect), the
* `connectionRequested` gate MUST be cleared so subsequent BLE state emissions cannot restart the transport.
* Without the gate-clear in setDeviceAddress(), BT recovery (user toggled BT off→on) would silently resurrect a
* transport for a device the user explicitly deselected β€” leaving the app "connected" to an unselected device with
* no orchestrator collector.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`BLE state recovery does not restart transport after setDeviceAddress deselect`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Te6edf3clock Tff7b72= T79c0ff0L
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Ta5d6ff"Ta5d6ffxAA:BB:CC:DD:EE:FFTa5d6ff"Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4, Ta5d6ff"Ta5d6ffInitial connect should create one transportTa5d6ff"Tb4b4b4)

T8b949e// Explicit device deselect: setDeviceAddress(null)/("n") must clear the connectionRequested
T8b949e// gate BEFORE stopTransportLocked() so a racing state-listener emission cannot re-arm it.
Te6edf3serviceTb4b4b4.Te6edf3setDeviceAddressTb4b4b4(Ta5d6ff"Ta5d6ffnTa5d6ff"Tb4b4b4)
T8b949e// Drain the polite-disconnect frame (production waits POLITE_DISCONNECT_DRAIN_MS = 500ms).
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Tff7b72val Te6edf3transportCountAfterDeselect Tff7b72= Te6edf3createdTransportsTb4b4b4.Te6edf3size

T8b949e// Force a fresh BLE state cycle (disabled β†’ enabled). The initial listener subscription
T8b949e// already consumed the default enabled=true emission during connect(), so toggling is
T8b949e// required to deliver a NEW enabled=true emission that would trigger startTransportLocked().
Te6edf3bluetoothRepositoryTb4b4b4.Te6edf3setBluetoothEnabledTb4b4b4(Tff7b72falseTb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3bluetoothRepositoryTb4b4b4.Te6edf3setBluetoothEnabledTb4b4b4(Tff7b72trueTb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Te6edf3assertEqualsTb4b4b4(
Te6edf3transportCountAfterDeselectTb4b4b4,
Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4,
Ta5d6ff"Ta5d6ffBT-enabled emission after deselect must NOT restart transport (connectionRequested gate cleared)Ta5d6ff"Tb4b4b4,
Tb4b4b4)
Te6edf3assertFalseTb4b4b4(
Te6edf3serviceTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3value Tff7b72=Tff7b72= Te6edf3ConnectionStateTb4b4b4.Te6edf3ConnectedTb4b4b4,
Ta5d6ff"Ta5d6ffState must NOT be Connected after post-deselect BT recovery emissionTa5d6ff"Tb4b4b4,
Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e/**
* Network/TCP counterpart to `BLE state recovery does not restart transport after setDeviceAddress deselect`.
*
* After [SharedRadioInterfaceService.setDeviceAddress] with `null`/`"n"`, a network-available emission MUST NOT
* resurrect the transport. Without the gate-clear in setDeviceAddress(), network recovery (Wi-Fi toggled off→on,
* network handoff) would silently restart a transport for a device the user explicitly deselected.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`network available recovery does not restart transport after setDeviceAddress deselect`Tb4b4b4(Tb4b4b4) Tff7b72=
Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Te6edf3clock Tff7b72= T79c0ff0L
Tff7b72val Te6edf3networkAvailability Tff7b72= Te6edf3MutableStateFlowTff7b72<Tffa657BooleanTff7b72>Tb4b4b4(Tff7b72trueTb4b4b4)
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Ta5d6ff"Ta5d6fft192.168.1.100Ta5d6ff"Tb4b4b4, Te6edf3networkAvailability Tff7b72= Te6edf3networkAvailabilityTb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4, Ta5d6ff"Ta5d6ffInitial connect should create one transportTa5d6ff"Tb4b4b4)

T8b949e// Explicit device deselect: setDeviceAddress(null)/("n") must clear the connectionRequested
T8b949e// gate BEFORE stopTransportLocked() so a racing network-listener emission cannot re-arm it.
Te6edf3serviceTb4b4b4.Te6edf3setDeviceAddressTb4b4b4(Ta5d6ff"Ta5d6ffnTa5d6ff"Tb4b4b4)
T8b949e// Drain the polite-disconnect frame (production waits POLITE_DISCONNECT_DRAIN_MS = 500ms).
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Tff7b72val Te6edf3transportCountAfterDeselect Tff7b72= Te6edf3createdTransportsTb4b4b4.Te6edf3size

T8b949e// Force a fresh network-available cycle (false β†’ true). The initial listener subscription
T8b949e// already consumed the default true emission during connect(), so toggling is required to
T8b949e// deliver a NEW true emission that would trigger startTransportLocked().
Te6edf3networkAvailabilityTb4b4b4.Te6edf3value Tff7b72= Tff7b72false
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3networkAvailabilityTb4b4b4.Te6edf3value Tff7b72= Tff7b72true
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Te6edf3assertEqualsTb4b4b4(
Te6edf3transportCountAfterDeselectTb4b4b4,
Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4,
Ta5d6ff"Ta5d6ffnetwork-available emission after deselect must NOT restart transport (connectionRequested gate cleared)Ta5d6ff"Tb4b4b4,
Tb4b4b4)
Te6edf3assertFalseTb4b4b4(
Te6edf3serviceTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3value Tff7b72=Tff7b72= Te6edf3ConnectionStateTb4b4b4.Te6edf3ConnectedTb4b4b4,
Ta5d6ff"Ta5d6ffState must NOT be Connected after post-deselect network recovery emissionTa5d6ff"Tb4b4b4,
Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e// ─── restartTransport gate contract ─────────────────────────────────────────────────────
T8b949e//
T8b949e// [SharedRadioInterfaceService.restartTransport] is the app-level handshake-stall recovery
T8b949e// path. It mirrors BLE liveness silent recovery: stopTransportLocked(notifyPermanent=false,
T8b949e// sendPoliteDisconnect=false) then startTransportLocked(). The isRestarting CAS runs
T8b949e// synchronously BEFORE transportMutex (mirroring checkLiveness), then the remaining gates run
T8b949e// inside the mutex. The gate contract has four early-return gates that MUST all hold for the
T8b949e// cycle to fire, evaluated in this exact order:
T8b949e// 1. isRestarting.compareAndSet(false, true) succeeds (reuses the liveness CAS; synchronous,
T8b949e// BEFORE acquiring transportMutex β€” both restart paths serialize on this CAS first)
T8b949e// 2. connectionRequested == true (cleared by disconnect() / setDeviceAddress(null)/("n");
T8b949e// checked inside transportMutex)
T8b949e// 3. getBondedDeviceAddress() != null (re-validates the selected address; inside transportMutex)
T8b949e// 4. radioTransport != null (defends against a stale restart after an environmental stop;
T8b949e// inside transportMutex)
T8b949e// The cycle must also be address-preserving and silent (no permanent Disconnected, no
T8b949e// user-facing error). The tests below lock each leg of that contract.

T8b949e/**
* Happy path: [SharedRadioInterfaceService.restartTransport] after a normal [connect] with a selected address stops
* the running transport and creates a fresh one. Mirrors the BLE liveness "timeout closes old transport and creates
* fresh one" assertion shape.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`restartTransport after connect closes old transport and creates fresh one`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Te6edf3clock Tff7b72= T79c0ff0L
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Ta5d6ff"Ta5d6ffxAA:BB:CC:DD:EE:FFTa5d6ff"Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4, Ta5d6ff"Ta5d6ffInitial connect should create one transportTa5d6ff"Tb4b4b4)
Tff7b72val Te6edf3initialTransport Tff7b72= Te6edf3createdTransportsTb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4)

T8b949e// Lock the sendPoliteDisconnect=false contract: clear any bytes recorded during the
T8b949e// initial connect so sentData reflects only writes performed during restartTransport.
Te6edf3initialTransportTb4b4b4.Te6edf3sentDataTb4b4b4.Te6edf3clearTb4b4b4(Tb4b4b4)

Te6edf3serviceTb4b4b4.Te6edf3restartTransportTb4b4b4(Tb4b4b4)
T8b949e// sendPoliteDisconnect = false β†’ no 500ms drain inside the cycle. Under
T8b949e// UnconfinedTestDispatcher the whole stop/start runs inline; runCurrent is
T8b949e// belt-and-suspenders. The trailing disconnect() covers its own polite delay.
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)

Te6edf3assertEqualsTb4b4b4(T79c0ff2Tb4b4b4, Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4, Ta5d6ff"Ta5d6ffrestartTransport should create exactly one fresh transportTa5d6ff"Tb4b4b4)
Te6edf3assertTrueTb4b4b4(Te6edf3initialTransportTb4b4b4.Te6edf3closeCalledTb4b4b4, Ta5d6ff"Ta5d6ffOld transport must be closed by restartTransportTa5d6ff"Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3initialTransportTb4b4b4.Te6edf3closeCountTb4b4b4, Ta5d6ff"Ta5d6ffOld transport closed exactly once (no double-close)Ta5d6ff"Tb4b4b4)
Te6edf3assertTrueTb4b4b4(
Te6edf3initialTransportTb4b4b4.Te6edf3sentDataTb4b4b4.Te6edf3isEmptyTb4b4b4(Tb4b4b4)Tb4b4b4,
Ta5d6ff"Ta5d6ffrestartTransport must NOT write any bytes to the old transport (sendPoliteDisconnect=false)Ta5d6ff"Tb4b4b4,
Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e/**
* Regression: [SharedRadioInterfaceService.restartTransport] MUST be a no-op after an explicit
* [SharedRadioInterfaceService.disconnect]. disconnect() clears `connectionRequested` BEFORE stopTransportLocked(),
* and restartTransport() consults that gate as its first check. Without the gate, a racing handshake-induced
* restart would silently resurrect a transport the user tore down.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`restartTransport is a no-op after explicit disconnect`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Te6edf3clock Tff7b72= T79c0ff0L
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Ta5d6ff"Ta5d6ffxAA:BB:CC:DD:EE:FFTa5d6ff"Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4, Ta5d6ff"Ta5d6ffInitial connect should create one transportTa5d6ff"Tb4b4b4)

Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tff7b72val Te6edf3transportCountAfterDisconnect Tff7b72= Te6edf3createdTransportsTb4b4b4.Te6edf3size

Te6edf3serviceTb4b4b4.Te6edf3restartTransportTb4b4b4(Tb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)

Te6edf3assertEqualsTb4b4b4(
Te6edf3transportCountAfterDisconnectTb4b4b4,
Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4,
Ta5d6ff"Ta5d6ffrestartTransport must not create a transport after disconnect (connectionRequested gate)Ta5d6ff"Tb4b4b4,
Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e/**
* Regression: [SharedRadioInterfaceService.restartTransport] MUST be a no-op after
* [SharedRadioInterfaceService.setDeviceAddress] with `null`. The deselect clears `connectionRequested` AND leaves
* getBondedDeviceAddress() == null (invalid address); either gate alone is sufficient to skip the restart, but both
* must hold to defend against a stale listener emission re-arming the transport.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`restartTransport is a no-op after setDeviceAddress null`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Te6edf3clock Tff7b72= T79c0ff0L
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Ta5d6ff"Ta5d6ffxAA:BB:CC:DD:EE:FFTa5d6ff"Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4, Ta5d6ff"Ta5d6ffInitial connect should create one transportTa5d6ff"Tb4b4b4)

Te6edf3serviceTb4b4b4.Te6edf3setDeviceAddressTb4b4b4(Tff7b72nullTb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tff7b72val Te6edf3transportCountAfterDeselect Tff7b72= Te6edf3createdTransportsTb4b4b4.Te6edf3size

Te6edf3serviceTb4b4b4.Te6edf3restartTransportTb4b4b4(Tb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)

Te6edf3assertEqualsTb4b4b4(
Te6edf3transportCountAfterDeselectTb4b4b4,
Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4,
Ta5d6ff"Ta5d6ffrestartTransport must not create a transport after setDeviceAddress(null)Ta5d6ff"Tb4b4b4,
Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e/**
* Counterpart to the null-deselect test: `"n"` is the UI sentinel for "no device" and is sanitized to `null` inside
* [SharedRadioInterfaceService.setDeviceAddress]. The same gate must skip restartTransport for both forms.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`restartTransport is a no-op after setDeviceAddress n`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Te6edf3clock Tff7b72= T79c0ff0L
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Ta5d6ff"Ta5d6ffxAA:BB:CC:DD:EE:FFTa5d6ff"Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4, Ta5d6ff"Ta5d6ffInitial connect should create one transportTa5d6ff"Tb4b4b4)

Te6edf3serviceTb4b4b4.Te6edf3setDeviceAddressTb4b4b4(Ta5d6ff"Ta5d6ffnTa5d6ff"Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tff7b72val Te6edf3transportCountAfterDeselect Tff7b72= Te6edf3createdTransportsTb4b4b4.Te6edf3size

Te6edf3serviceTb4b4b4.Te6edf3restartTransportTb4b4b4(Tb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)

Te6edf3assertEqualsTb4b4b4(
Te6edf3transportCountAfterDeselectTb4b4b4,
Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4,
Ta5d6ff"Ta5d6ffrestartTransport must not create a transport after setDeviceAddress(Ta5d6ff\"Ta5d6ffnTa5d6ff\"Ta5d6ff)Ta5d6ff"Tb4b4b4,
Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e/**
* Regression: [SharedRadioInterfaceService.restartTransport] cycles the transport in place WITHOUT writing a new
* devAddr or clearing currentDeviceAddressFlow. The caller (MeshConnectionManager) is the sole owner of address
* changes; restartTransport must never silently re-bind to a different device or evict the selection.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`restartTransport preserves selected device address`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Te6edf3clock Tff7b72= T79c0ff0L
Tff7b72val Te6edf3address Tff7b72= Ta5d6ff"Ta5d6ffxAA:BB:CC:DD:EE:FFTa5d6ff"
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Te6edf3addressTb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4, Ta5d6ff"Ta5d6ffInitial connect should create one transportTa5d6ff"Tb4b4b4)
Tff7b72val Te6edf3addrBefore Tff7b72= Te6edf3serviceTb4b4b4.Te6edf3getDeviceAddressTb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3prefsAddrBefore Tff7b72= Te6edf3radioPrefsTb4b4b4.Te6edf3devAddrTb4b4b4.Te6edf3value

Te6edf3serviceTb4b4b4.Te6edf3restartTransportTb4b4b4(Tb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)

Te6edf3assertEqualsTb4b4b4(
Te6edf3addrBeforeTb4b4b4,
Te6edf3serviceTb4b4b4.Te6edf3getDeviceAddressTb4b4b4(Tb4b4b4)Tb4b4b4,
Ta5d6ff"Ta5d6ffgetDeviceAddress must not change across restartTransportTa5d6ff"Tb4b4b4,
Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(Te6edf3addressTb4b4b4, Te6edf3serviceTb4b4b4.Te6edf3getDeviceAddressTb4b4b4(Tb4b4b4)Tb4b4b4, Ta5d6ff"Ta5d6ffSelected device address preservedTa5d6ff"Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(
Te6edf3prefsAddrBeforeTb4b4b4,
Te6edf3radioPrefsTb4b4b4.Te6edf3devAddrTb4b4b4.Te6edf3valueTb4b4b4,
Ta5d6ff"Ta5d6ffradioPrefs.devAddr must not be rewritten by restartTransportTa5d6ff"Tb4b4b4,
Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e/**
* Regression: [SharedRadioInterfaceService.restartTransport] mirrors BLE liveness silent recovery. It MUST emit a
* transient DeviceSleep (via onDisconnect(isPermanent = false)) BEFORE the stop/start cycle so the subsequent
* onConnect() produces a real Connected transition (StateFlow is idempotent on same-value β€” without the DeviceSleep
* flip, Connected -> Connected is a no-op and MeshConnectionManager stays stuck on its app-level Disconnected
* state). It MUST NOT emit a permanent user-facing Disconnected state β€” the caller drives app-level state
* transitions separately, and surfacing a permanent disconnect for a self-healing cycle would pop a confusing modal
* for a transient condition.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`restartTransport does not emit permanent Disconnected`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Te6edf3clock Tff7b72= T79c0ff0L
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Ta5d6ff"Ta5d6ffxAA:BB:CC:DD:EE:FFTa5d6ff"Tb4b4b4)
Tff7b72try Tb4b4b4{
Tff7b72val Te6edf3stateEmissions Tff7b72= Te6edf3mutableListOfTff7b72<Te6edf3ConnectionStateTff7b72>Tb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3collectJob Tff7b72= Te6edf3backgroundScopeTb4b4b4.Te6edf3launch Tb4b4b4{ Te6edf3serviceTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3collect Tb4b4b4{ Te6edf3stateEmissionsTb4b4b4.Te6edf3addTb4b4b4(Tffa657itTb4b4b4) Tb4b4b4} Tb4b4b4}

Te6edf3serviceTb4b4b4.Te6edf3restartTransportTb4b4b4(Tb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)

Te6edf3collectJobTb4b4b4.Te6edf3cancelTb4b4b4(Tb4b4b4)

T8b949e// The DeviceSleep emission is the intended transport-level transition that lets
T8b949e// MeshConnectionManager observe a real Connected transition on the fresh transport.
Te6edf3assertTrueTb4b4b4(
Te6edf3ConnectionStateTb4b4b4.Te6edf3DeviceSleep Tff7b72in Te6edf3stateEmissionsTb4b4b4,
Ta5d6ff"Ta5d6ffrestartTransport must emit transient DeviceSleep so the post-restart onConnect() re-triggers handleConnected() (emitted: Tffd700$Te6edf3stateEmissionsTa5d6ff)Ta5d6ff"Tb4b4b4,
Tb4b4b4)
Te6edf3assertFalseTb4b4b4(
Te6edf3ConnectionStateTb4b4b4.Te6edf3Disconnected Tff7b72in Te6edf3stateEmissionsTb4b4b4,
Ta5d6ff"Ta5d6ffrestartTransport must not emit permanent Disconnected state (emitted: Tffd700$Te6edf3stateEmissionsTa5d6ff)Ta5d6ff"Tb4b4b4,
Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e/**
* Regression: [SharedRadioInterfaceService.restartTransport] must not emit a user-facing connection error.
* _connectionError is a no-replay SharedFlow, so the collector must subscribe BEFORE the restart; under
* UnconfinedTestDispatcher the launch runs eagerly up to its first suspension (awaiting SharedFlow emission). A
* silent-recovery cycle surfacing an error modal is the same UX bug as a liveness recovery surfacing one.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`restartTransport does not emit user-facing connection error`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Te6edf3clock Tff7b72= T79c0ff0L
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Ta5d6ff"Ta5d6ffxAA:BB:CC:DD:EE:FFTa5d6ff"Tb4b4b4)
Tff7b72try Tb4b4b4{
Tff7b72val Te6edf3errors Tff7b72= Te6edf3mutableListOfTff7b72<Tffa657StringTff7b72>Tb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3collectJob Tff7b72= Te6edf3backgroundScopeTb4b4b4.Te6edf3launch Tb4b4b4{ Te6edf3serviceTb4b4b4.Te6edf3connectionErrorTb4b4b4.Te6edf3collect Tb4b4b4{ Te6edf3errorsTb4b4b4.Te6edf3addTb4b4b4(Tffa657itTb4b4b4) Tb4b4b4} Tb4b4b4}

Te6edf3serviceTb4b4b4.Te6edf3restartTransportTb4b4b4(Tb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)

Te6edf3collectJobTb4b4b4.Te6edf3cancelTb4b4b4(Tb4b4b4)

Te6edf3assertTrueTb4b4b4(
Te6edf3errorsTb4b4b4.Te6edf3isEmptyTb4b4b4(Tb4b4b4)Tb4b4b4,
Ta5d6ff"Ta5d6ffrestartTransport must not emit user-facing connection error (got: Tffd700$Te6edf3errorsTa5d6ff)Ta5d6ff"Tb4b4b4,
Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e/**
* Regression: [SharedRadioInterfaceService.restartTransport] reuses the BLE liveness `isRestarting` CAS so a
* concurrent liveness silent-recovery cycle and a handshake-induced restart cannot stack. The loser of the CAS
* observes `isRestarting == true` and defers to the in-flight cycle, preventing a double stop/start race on the
* transport.
*
* restartTransport performs the isRestarting CAS synchronously BEFORE acquiring transportMutex (mirroring
* checkLiveness). Under this pattern, a concurrent liveness restart and a handshake-induced restart serialize on
* the CAS first β€” the loser returns immediately without touching the mutex. This is deterministic on all
* dispatchers, not just UnconfinedTestDispatcher.
*
* Deterministic in-flight overlap: a [GatedFakeRadioTransport] suspends the liveness restart genuinely inside
* stopTransportLocked β†’ close() (awaiting closeGate). While the liveness coroutine holds `isRestarting == true`
* inside its restart cycle, a concurrent `restartTransport()` call CAS-fails on `isRestarting` and returns
* immediately without ever acquiring `transportMutex`. A stacking bug (CAS ignored, or CAS performed inside the
* mutex) would produce 3 transports.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`restartTransport coordinates with in-flight liveness restart via isRestarting`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3gatedTransports Tff7b72= Te6edf3mutableListOfTff7b72<Te6edf3GatedFakeRadioTransportTff7b72>Tb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3closeGate Tff7b72= Te6edf3CompletableDeferredTff7b72<Tffa657UnitTff7b72>Tb4b4b4(Tb4b4b4)
T8b949e// Publish the gate to activeCloseGate so tearDown can release it even if an assertion
T8b949e// throws before we reach the inner try/finally β€” otherwise disconnect() below would
T8b949e// hang forever on the gated close().
Te6edf3activeCloseGate Tff7b72= Te6edf3closeGate
Tff7b72val Te6edf3transportProviderTb4b4b4: Tb4b4b4(Tb4b4b4) Tff7b72-Tff7b72> Te6edf3RadioTransport Tff7b72= Tb4b4b4{
Te6edf3GatedFakeRadioTransportTb4b4b4(Te6edf3closeGateTb4b4b4)Tb4b4b4.Te6edf3also Tb4b4b4{ Te6edf3gatedTransportsTb4b4b4.Te6edf3addTb4b4b4(Tffa657itTb4b4b4) Tb4b4b4}
Tb4b4b4}

Te6edf3clock Tff7b72= T79c0ff0L
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Ta5d6ff"Ta5d6ffxAA:BB:CC:DD:EE:FFTa5d6ff"Tb4b4b4, Te6edf3transportProviderTb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3gatedTransportsTb4b4b4.Te6edf3sizeTb4b4b4, Ta5d6ff"Ta5d6ffInitial connect should create one transportTa5d6ff"Tb4b4b4)
Tff7b72val Te6edf3initialTransport Tff7b72= Te6edf3gatedTransportsTb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4)

T8b949e// Past the 60s threshold β†’ first checkLiveness CAS-sets isRestarting=true and
T8b949e// launches a restart coroutine whose close() suspends on closeGate. Under
T8b949e// UnconfinedTestDispatcher the launched coroutine runs eagerly up to the
T8b949e// suspension point: by the time checkLiveness() returns, mutex is held and
T8b949e// isRestarting == true.
Te6edf3clock Tff7b72= T79c0ff6T79c0ff5Te6edf3_000L
Te6edf3serviceTb4b4b4.Te6edf3checkLivenessTb4b4b4(Tb4b4b4)

T8b949e// Issue restartTransport() while the liveness restart is in-flight. Under the refactored
T8b949e// CAS-before-mutex pattern, restartTransport's CAS fails immediately (isRestarting is already
T8b949e// true from checkLiveness's synchronous CAS). It returns without acquiring the mutex.
Tff7b72val Te6edf3restartJob Tff7b72= Te6edf3backgroundScopeTb4b4b4.Te6edf3launch Tb4b4b4{ Te6edf3serviceTb4b4b4.Te6edf3restartTransportTb4b4b4(Tb4b4b4) Tb4b4b4}
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)

Tff7b72try Tb4b4b4{
T8b949e// Pre-release: liveness still in-flight (close() suspended on closeGate),
T8b949e// but restartTransport already returned via CAS-fail on isRestarting (it never
T8b949e// touched transportMutex). No fresh transport from restartTransport, no stacking.
Te6edf3assertEqualsTb4b4b4(
T79c0ff1Tb4b4b4,
Te6edf3gatedTransportsTb4b4b4.Te6edf3sizeTb4b4b4,
Ta5d6ff"Ta5d6ffNo fresh transport created while liveness restart is in-flightTa5d6ff"Tb4b4b4,
Tb4b4b4)
Te6edf3assertTrueTb4b4b4(Te6edf3initialTransportTb4b4b4.Te6edf3closeCalledTb4b4b4, Ta5d6ff"Ta5d6ffLiveness restart must have entered close()Ta5d6ff"Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(
T79c0ff0Tb4b4b4,
Te6edf3initialTransportTb4b4b4.Te6edf3closeCompletedCountTb4b4b4,
Ta5d6ff"Ta5d6ffLiveness restart close() must still be suspended (gate not yet released)Ta5d6ff"Tb4b4b4,
Tb4b4b4)
Te6edf3assertTrueTb4b4b4(
Te6edf3restartJobTb4b4b4.Te6edf3isCompletedTb4b4b4,
Ta5d6ff"Ta5d6ffrestartTransport should CAS-fail immediately when liveness already holds isRestarting, Ta5d6ff" Tff7b72+
Ta5d6ff"Ta5d6ffnot queue on transportMutexTa5d6ff"Tb4b4b4,
Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
T8b949e// Release the gate unconditionally so the suspended liveness restart can
T8b949e// complete. tearDown also releases activeCloseGate, but completing here is
T8b949e// required for the post-finally assertions to observe the resumed cycle.
Te6edf3closeGateTb4b4b4.Te6edf3completeTb4b4b4(Tffa657UnitTb4b4b4)
Tb4b4b4}

T8b949e// Release the gate: liveness restart completes (close + startTransport).
T8b949e// restartTransport already returned via CAS-fail, so no second cycle. Exactly 2 transports.
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Te6edf3restartJobTb4b4b4.Te6edf3joinTb4b4b4(Tb4b4b4)

T8b949e// Exactly 2 transports: 1 initial + 1 from liveness restart. restartTransport
T8b949e// was a no-op via the isRestarting CAS. A stacking bug would produce 3.
Te6edf3assertEqualsTb4b4b4(
T79c0ff2Tb4b4b4,
Te6edf3gatedTransportsTb4b4b4.Te6edf3sizeTb4b4b4,
Ta5d6ff"Ta5d6ffrestartTransport must not stack another cycle on an in-flight liveness restartTa5d6ff"Tb4b4b4,
Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3initialTransportTb4b4b4.Te6edf3closeCountTb4b4b4, Ta5d6ff"Ta5d6ffInitial transport closed exactly onceTa5d6ff"Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(
T79c0ff1Tb4b4b4,
Te6edf3initialTransportTb4b4b4.Te6edf3closeCompletedCountTb4b4b4,
Ta5d6ff"Ta5d6ffInitial transport close completed exactly once after gate releaseTa5d6ff"Tb4b4b4,
Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e/**
* Regression for the `radioTransport == null` gate added to [SharedRadioInterfaceService.restartTransport] (the
* mutex-protected early-return, checked after the `isRestarting` CAS but before the `onDisconnect(isPermanent =
* false)` call).
*
* Scenario: a TCP transport has been torn down by an environmental stop (`networkAvailable = false`) but
* `connectionRequested` is intentionally preserved so the network recovery listener can re-bring-up the transport
* when connectivity returns. A stale handshake-stall restart fired in that window MUST be a no-op:
* - It MUST NOT create a fresh transport via `startTransportLocked()` (that would bypass the recovery listener,
* which owns the re-bring-up).
* - It MUST NOT emit `DeviceSleep` via `onDisconnect(isPermanent = false)` (the gate returns before that call), so
* the recovery path's later transitions stay meaningful.
*
* Counter-assertion: the recovery listener itself MUST still function after the gate fires β€” toggling
* `networkAvailable` back to `true` MUST create the second transport, proving the gate did not break the documented
* re-bring-up path.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`restartTransport is a no-op when transport is environmentally stopped but connection remains requested`Tb4b4b4(Tb4b4b4) Tff7b72=
Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Te6edf3clock Tff7b72= T79c0ff0L
Tff7b72val Te6edf3networkAvailability Tff7b72= Te6edf3MutableStateFlowTff7b72<Tffa657BooleanTff7b72>Tb4b4b4(Tff7b72trueTb4b4b4)
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Ta5d6ff"Ta5d6fft192.168.1.100Ta5d6ff"Tb4b4b4, Te6edf3networkAvailability Tff7b72= Te6edf3networkAvailabilityTb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4, Ta5d6ff"Ta5d6ffInitial connect should create one transportTa5d6ff"Tb4b4b4)
Tff7b72val Te6edf3initialTransport Tff7b72= Te6edf3createdTransportsTb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4)

T8b949e// Subscribe BEFORE the environmental stop so we capture every state transition through
T8b949e// the stop, the gated restartTransport, and the recovery. _connectionState is a StateFlow
T8b949e// (replays current value to late collectors), so the launch's first emission is Connected.
Tff7b72val Te6edf3stateEmissions Tff7b72= Te6edf3mutableListOfTff7b72<Te6edf3ConnectionStateTff7b72>Tb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3collectJob Tff7b72= Te6edf3backgroundScopeTb4b4b4.Te6edf3launch Tb4b4b4{ Te6edf3serviceTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3collect Tb4b4b4{ Te6edf3stateEmissionsTb4b4b4.Te6edf3addTb4b4b4(Tffa657itTb4b4b4) Tb4b4b4} Tb4b4b4}
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)

T8b949e// Environmental stop: network drops while a TCP transport is running. The network
T8b949e// listener (see initStateListeners) calls stopTransportLocked() with defaults
T8b949e// (notifyPermanent=true β†’ onDisconnect(isPermanent=true) β†’ Disconnected). The transport
T8b949e// is closed and radioTransport becomes null, but connectionRequested STAYS true so the
T8b949e// recovery listener can re-bring-up later.
Te6edf3networkAvailabilityTb4b4b4.Te6edf3value Tff7b72= Tff7b72false
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
T8b949e// Drain the polite-disconnect frame (POLITE_DISCONNECT_DRAIN_MS = 500ms).
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Te6edf3assertTrueTb4b4b4(Te6edf3initialTransportTb4b4b4.Te6edf3closeCalledTb4b4b4, Ta5d6ff"Ta5d6ffEnvironmental stop must close the running TCP transportTa5d6ff"Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(
T79c0ff1Tb4b4b4,
Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4,
Ta5d6ff"Ta5d6ffEnvironmental stop must not create a new transport (only teardown)Ta5d6ff"Tb4b4b4,
Tb4b4b4)

T8b949e// Stale handshake-stall restart fired in the window where radioTransport == null but
T8b949e// connectionRequested is still true. The new gate must short-circuit AFTER the
T8b949e// isRestarting CAS (inside transportMutex) but BEFORE the onDisconnect(isPermanent=false) DeviceSleep
T8b949e// emission.
Te6edf3serviceTb4b4b4.Te6edf3restartTransportTb4b4b4(Tb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Te6edf3assertEqualsTb4b4b4(
T79c0ff1Tb4b4b4,
Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4,
Ta5d6ff"Ta5d6ffrestartTransport must be a no-op when radioTransport == null (gate fired)Ta5d6ff"Tb4b4b4,
Tb4b4b4)
Te6edf3assertFalseTb4b4b4(
Te6edf3ConnectionStateTb4b4b4.Te6edf3DeviceSleep Tff7b72in Te6edf3stateEmissionsTb4b4b4,
Ta5d6ff"Ta5d6ffGated restartTransport must NOT emit DeviceSleep Ta5d6ff" Tff7b72+
Ta5d6ff"Ta5d6ff(returned before onDisconnect(isPermanent=false)); emitted: Tffd700$Te6edf3stateEmissionsTa5d6ff"Tb4b4b4,
Tb4b4b4)

T8b949e// Environmental recovery: networkAvailable flips back to true. connectionRequested is
T8b949e// still true (neither the environmental stop nor the gated restart cleared it), so the
T8b949e// recovery listener MUST call startTransportLocked() and create the second transport.
T8b949e// This proves the new gate did not break the documented re-bring-up path.
Te6edf3networkAvailabilityTb4b4b4.Te6edf3value Tff7b72= Tff7b72true
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Te6edf3collectJobTb4b4b4.Te6edf3cancelTb4b4b4(Tb4b4b4)

Te6edf3assertEqualsTb4b4b4(
T79c0ff2Tb4b4b4,
Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4,
Ta5d6ff"Ta5d6ffNetwork recovery must re-bring-up the transport after the gated no-op restart (gate still set)Ta5d6ff"Tb4b4b4,
Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e// ─── USB serial replug observer (observeUsbRecoveryTriggers) ───────────────────────────
T8b949e//
T8b949e// The USB-serial recovery observer (see observeUsbRecoveryTriggers) watches the selected SERIAL device's
T8b949e// presence via [SerialDevicePresence.deviceKeys] combined with [currentDeviceAddressFlow] and
T8b949e// [_connectionState]. It arms recovery only after the SELECTED serial key has been observed absent and then
T8b949e// present again; when that armed edge converges with zombie DeviceSleep, it tears down the zombie transport
T8b949e// (left over from the unplug I/O-death path that emits DeviceSleep without nulling radioTransport) and starts a
T8b949e// fresh one β€” sparing the user a manual re-select.
T8b949e//
T8b949e// Pipeline (see SharedRadioInterfaceService.observeUsbRecoveryTriggers):
T8b949e// selectedPresence = combine(currentDeviceAddressFlow, serialDevicePresence.deviceKeys)
T8b949e// combine(selectedPresence, _connectionState)
T8b949e// .runningFold(...) // arms only after selected key goes absent β†’ present
T8b949e// .filter { triggerRecovery }
T8b949e// .onEach { transportMutex.withLock {
T8b949e// if (!connectionRequested) return@withLock
T8b949e// if (runningTransportId != InterfaceId.SERIAL) return@withLock
T8b949e// if (state is Connected || state is Connecting) return@withLock // race-defense
T8b949e// ignoreExceptionSuspend { stopTransportLocked(notifyPermanent = false, sendPoliteDisconnect = false) }
T8b949e// ignoreExceptionSuspend { startTransportLocked() }
T8b949e// } }
T8b949e//
T8b949e// The tests below drive the controllable [serialDeviceKeys] flow directly to exercise each branch of that gate
T8b949e// contract.

T8b949e/**
* Happy path: a SERIAL transport left in zombie DeviceSleep (after an unplug I/O death) is recovered automatically
* when the same USB device is replugged. The observer must call stopTransportLocked + startTransportLocked exactly
* once, producing exactly one fresh transport and closing the zombie.
*
* The zombie state mirrors what `SerialConnectionListener.onDisconnected β†’ onDeviceDisconnect` leaves behind: a
* transient DeviceSleep emission WITHOUT nulling radioTransport (the I/O-death path doesn't run
* stopTransportLocked). Only the observer's stop/start cycle reaps the zombie and builds a fresh transport for the
* replugged device.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`USB replug of selected serial device restarts transport when zombie`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Te6edf3clock Tff7b72= T79c0ff0L
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Ta5d6ff"Ta5d6ffs/dev/bus/usb/001/002Ta5d6ff"Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4, Ta5d6ff"Ta5d6ffInitial connect should create one transportTa5d6ff"Tb4b4b4)
Tff7b72val Te6edf3initialTransport Tff7b72= Te6edf3createdTransportsTb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4)

T8b949e// Simulate the unplug I/O-death path: SerialConnectionListener.onDisconnected β†’
T8b949e// onDeviceDisconnect emits a transient DeviceSleep but does NOT null radioTransport
T8b949e// (the zombie state the observer comment specifies it recovers from).
Te6edf3serviceTb4b4b4.Te6edf3onDisconnectTb4b4b4(Te6edf3isPermanent Tff7b72= Tff7b72falseTb4b4b4)
Te6edf3assertEqualsTb4b4b4(Te6edf3ConnectionStateTb4b4b4.Te6edf3DeviceSleepTb4b4b4, Te6edf3serviceTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3valueTb4b4b4, Ta5d6ff"Ta5d6ffPrecondition: zombie stateTa5d6ff"Tb4b4b4)

T8b949e// Replug: serialDevicePresence emits the device's `rest` key (the address minus its 's').
Te6edf3serialDeviceKeysTb4b4b4.Te6edf3value Tff7b72= Te6edf3setOfTb4b4b4(Ta5d6ff"Ta5d6ff/dev/bus/usb/001/002Ta5d6ff"Tb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Te6edf3assertEqualsTb4b4b4(T79c0ff2Tb4b4b4, Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4, Ta5d6ff"Ta5d6ffReplug should create exactly one fresh transportTa5d6ff"Tb4b4b4)
Te6edf3assertTrueTb4b4b4(Te6edf3initialTransportTb4b4b4.Te6edf3closeCalledTb4b4b4, Ta5d6ff"Ta5d6ffZombie transport must be closed by stopTransportLockedTa5d6ff"Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3initialTransportTb4b4b4.Te6edf3closeCountTb4b4b4, Ta5d6ff"Ta5d6ffZombie transport closed exactly once (no double-close)Ta5d6ff"Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e/**
* Negative counterpart: only the SELECTED device's key triggers recovery. A different USB device being plugged in
* must not perturb the selected transport β€” `rest in keys` evaluates false, the combined flow stays false, and
* distinctUntilChanged swallows the redundant false emission.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`USB replug of unrelated serial device does not restart transport`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Te6edf3clock Tff7b72= T79c0ff0L
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Ta5d6ff"Ta5d6ffs/dev/bus/usb/001/002Ta5d6ff"Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4, Ta5d6ff"Ta5d6ffInitial connect should create one transportTa5d6ff"Tb4b4b4)

Te6edf3serviceTb4b4b4.Te6edf3onDisconnectTb4b4b4(Te6edf3isPermanent Tff7b72= Tff7b72falseTb4b4b4)
Te6edf3assertEqualsTb4b4b4(Te6edf3ConnectionStateTb4b4b4.Te6edf3DeviceSleepTb4b4b4, Te6edf3serviceTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3valueTb4b4b4, Ta5d6ff"Ta5d6ffPrecondition: zombie stateTa5d6ff"Tb4b4b4)

T8b949e// A different physical device β€” different `rest` key from the selected address.
Te6edf3serialDeviceKeysTb4b4b4.Te6edf3value Tff7b72= Te6edf3setOfTb4b4b4(Ta5d6ff"Ta5d6ff/dev/bus/usb/001/003Ta5d6ff"Tb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4, Ta5d6ff"Ta5d6ffUnrelated device replug must NOT restart transportTa5d6ff"Tb4b4b4)
Te6edf3assertFalseTb4b4b4(
Te6edf3createdTransportsTb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4)Tb4b4b4.Te6edf3closeCalledTb4b4b4,
Ta5d6ff"Ta5d6ffZombie transport must NOT be closed for an unrelated deviceTa5d6ff"Tb4b4b4,
Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e/**
* Healthy-state guard: when the selected device is already Connected (or Connecting), the observer must NOT fire a
* restart. This guards the cold-flow false β†’ true transition that happens when the user selects an already-present
* USB device β€” setDeviceAddress has already brought the transport up to Connected, so a "recovery" cycle would be a
* redundant teardown of a healthy link.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`USB replug does not restart transport when already Connected`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Te6edf3clock Tff7b72= T79c0ff0L
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Ta5d6ff"Ta5d6ffs/dev/bus/usb/001/002Ta5d6ff"Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4, Ta5d6ff"Ta5d6ffInitial connect should create one transportTa5d6ff"Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(Te6edf3ConnectionStateTb4b4b4.Te6edf3ConnectedTb4b4b4, Te6edf3serviceTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3valueTb4b4b4, Ta5d6ff"Ta5d6ffPrecondition: Connected stateTa5d6ff"Tb4b4b4)

T8b949e// Device present in the keys β€” but state is Connected (healthy), not zombie.
Te6edf3serialDeviceKeysTb4b4b4.Te6edf3value Tff7b72= Te6edf3setOfTb4b4b4(Ta5d6ff"Ta5d6ff/dev/bus/usb/001/002Ta5d6ff"Tb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4, Ta5d6ff"Ta5d6ffHealthy-state guard must skip restart for Connected stateTa5d6ff"Tb4b4b4)
Te6edf3assertFalseTb4b4b4(Te6edf3createdTransportsTb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4)Tb4b4b4.Te6edf3closeCalledTb4b4b4, Ta5d6ff"Ta5d6ffHealthy transport must NOT be closedTa5d6ff"Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e/**
* Regression: the unplug/replug race in which the USB presence signal arrives BEFORE the I/O-death callback has
* flipped `_connectionState` to DeviceSleep. The combined trigger now includes `_connectionState` so that the
* subsequent Connected β†’ DeviceSleep transition re-emits the flow and fires recovery β€” without that, the
* early-return on Connected swallows the presence emission, the later state transition does not re-emit
* (distinctUntilChanged swallows true β†’ true), and the zombie transport never recovers.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`USB replug recovers when presence arrives before DeviceSleep`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Te6edf3clock Tff7b72= T79c0ff0L
Tff7b72val Te6edf3selectedKey Tff7b72= Ta5d6ff"Ta5d6ff/dev/bus/usb/001/002Ta5d6ff"
Te6edf3serialDeviceKeysTb4b4b4.Te6edf3value Tff7b72= Te6edf3setOfTb4b4b4(Te6edf3selectedKeyTb4b4b4)
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Ta5d6ff"Ta5d6ffsTffd700$Te6edf3selectedKeyTa5d6ff"Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4, Ta5d6ff"Ta5d6ffInitial connect should create one transportTa5d6ff"Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(Te6edf3ConnectionStateTb4b4b4.Te6edf3ConnectedTb4b4b4, Te6edf3serviceTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3valueTb4b4b4, Ta5d6ff"Ta5d6ffPrecondition: Connected stateTa5d6ff"Tb4b4b4)

T8b949e// The actual detach is observed first, while state is still Connected.
Te6edf3serialDeviceKeysTb4b4b4.Te6edf3value Tff7b72= Te6edf3emptySetTb4b4b4(Tb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4, Ta5d6ff"Ta5d6ffDetach while Connected must NOT restart transportTa5d6ff"Tb4b4b4)
Te6edf3assertFalseTb4b4b4(
Te6edf3createdTransportsTb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4)Tb4b4b4.Te6edf3closeCalledTb4b4b4,
Ta5d6ff"Ta5d6ffDetach while Connected must NOT close the transportTa5d6ff"Tb4b4b4,
Tb4b4b4)

T8b949e// Replug signal arrives WHILE state is still Connected β€” the trigger must NOT fire yet.
Te6edf3serialDeviceKeysTb4b4b4.Te6edf3value Tff7b72= Te6edf3setOfTb4b4b4(Te6edf3selectedKeyTb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4, Ta5d6ff"Ta5d6ffPresence while Connected must NOT restart transportTa5d6ff"Tb4b4b4)
Te6edf3assertFalseTb4b4b4(Te6edf3createdTransportsTb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4)Tb4b4b4.Te6edf3closeCalledTb4b4b4, Ta5d6ff"Ta5d6ffHealthy transport must NOT be closedTa5d6ff"Tb4b4b4)

T8b949e// The I/O-death callback now fires (late) and flips state to DeviceSleep. The combine
T8b949e// re-emits because state is one of its sources; recovery fires after the state catches up.
Te6edf3serviceTb4b4b4.Te6edf3onDisconnectTb4b4b4(Te6edf3isPermanent Tff7b72= Tff7b72falseTb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Te6edf3assertEqualsTb4b4b4(T79c0ff2Tb4b4b4, Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4, Ta5d6ff"Ta5d6ffRecovery MUST fire once state catches up to DeviceSleepTa5d6ff"Tb4b4b4)
Te6edf3assertTrueTb4b4b4(
Te6edf3createdTransportsTb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4)Tb4b4b4.Te6edf3closeCalledTb4b4b4,
Ta5d6ff"Ta5d6ffZombie transport must be closed by stopTransportLockedTa5d6ff"Tb4b4b4,
Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e/**
* Regression: Android serialDevices starts as an empty snapshot before UsbRepository refreshes. That initial empty
* β†’ present emission is not a detach/replug edge and must not arm recovery for a later normal unplug callback.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`USB initial empty presence does not arm recovery`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Te6edf3clock Tff7b72= T79c0ff0L
Tff7b72val Te6edf3selectedKey Tff7b72= Ta5d6ff"Ta5d6ff/dev/bus/usb/001/002Ta5d6ff"
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Ta5d6ff"Ta5d6ffsTffd700$Te6edf3selectedKeyTa5d6ff"Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4, Ta5d6ff"Ta5d6ffInitial connect should create one transportTa5d6ff"Tb4b4b4)
Tff7b72val Te6edf3initialTransport Tff7b72= Te6edf3createdTransportsTb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(Te6edf3ConnectionStateTb4b4b4.Te6edf3ConnectedTb4b4b4, Te6edf3serviceTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3valueTb4b4b4, Ta5d6ff"Ta5d6ffPrecondition: Connected stateTa5d6ff"Tb4b4b4)

Te6edf3serialDeviceKeysTb4b4b4.Te6edf3value Tff7b72= Te6edf3setOfTb4b4b4(Te6edf3selectedKeyTb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4, Ta5d6ff"Ta5d6ffInitial empty β†’ present must NOT restart transportTa5d6ff"Tb4b4b4)
Te6edf3assertFalseTb4b4b4(Te6edf3initialTransportTb4b4b4.Te6edf3closeCalledTb4b4b4, Ta5d6ff"Ta5d6ffInitial presence refresh must not close the transportTa5d6ff"Tb4b4b4)

Te6edf3serviceTb4b4b4.Te6edf3onDisconnectTb4b4b4(Te6edf3isPermanent Tff7b72= Tff7b72falseTb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Te6edf3assertEqualsTb4b4b4(
T79c0ff1Tb4b4b4,
Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4,
Ta5d6ff"Ta5d6ffLater DeviceSleep with initially-present key must NOT restart transportTa5d6ff"Tb4b4b4,
Tb4b4b4)
Te6edf3assertFalseTb4b4b4(Te6edf3initialTransportTb4b4b4.Te6edf3closeCalledTb4b4b4, Ta5d6ff"Ta5d6ffUnplug callback alone must not close the transportTa5d6ff"Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e/**
* Regression: some USB stacks deliver the transport disconnect callback before UsbRepository has emitted the detach
* that removes the selected key. A present-at-start key plus DeviceSleep is only "normal unplug in progress", not a
* replug, so recovery must stay quiet until the observer sees the selected key go absent and then present again.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`USB disconnect before detach emission waits for absent to present replug`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Te6edf3clock Tff7b72= T79c0ff0L
Te6edf3serialDeviceKeysTb4b4b4.Te6edf3value Tff7b72= Te6edf3setOfTb4b4b4(Ta5d6ff"Ta5d6ff/dev/bus/usb/001/002Ta5d6ff"Tb4b4b4)
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Ta5d6ff"Ta5d6ffs/dev/bus/usb/001/002Ta5d6ff"Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4, Ta5d6ff"Ta5d6ffInitial connect should create one transportTa5d6ff"Tb4b4b4)
Tff7b72val Te6edf3initialTransport Tff7b72= Te6edf3createdTransportsTb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(Te6edf3ConnectionStateTb4b4b4.Te6edf3ConnectedTb4b4b4, Te6edf3serviceTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3valueTb4b4b4, Ta5d6ff"Ta5d6ffPrecondition: Connected stateTa5d6ff"Tb4b4b4)

T8b949e// The transport-level disconnect arrives before the deviceKeys detach emission. Because the key was
T8b949e// already present, this must not be treated as an absent β†’ present replug.
Te6edf3serviceTb4b4b4.Te6edf3onDisconnectTb4b4b4(Te6edf3isPermanent Tff7b72= Tff7b72falseTb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Te6edf3assertEqualsTb4b4b4(
T79c0ff1Tb4b4b4,
Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4,
Ta5d6ff"Ta5d6ffDeviceSleep with an already-present key must NOT restart transportTa5d6ff"Tb4b4b4,
Tb4b4b4)
Te6edf3assertFalseTb4b4b4(Te6edf3initialTransportTb4b4b4.Te6edf3closeCalledTb4b4b4, Ta5d6ff"Ta5d6ffUnplug callback alone must not close the transportTa5d6ff"Tb4b4b4)

T8b949e// Now the actual detach/attach sequence is visible: absent first, then present again.
Te6edf3serialDeviceKeysTb4b4b4.Te6edf3value Tff7b72= Te6edf3emptySetTb4b4b4(Tb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4, Ta5d6ff"Ta5d6ffDetach emission alone must not restart transportTa5d6ff"Tb4b4b4)
Te6edf3assertFalseTb4b4b4(Te6edf3initialTransportTb4b4b4.Te6edf3closeCalledTb4b4b4, Ta5d6ff"Ta5d6ffDetach emission alone must not close the transportTa5d6ff"Tb4b4b4)

Te6edf3serialDeviceKeysTb4b4b4.Te6edf3value Tff7b72= Te6edf3setOfTb4b4b4(Ta5d6ff"Ta5d6ff/dev/bus/usb/001/002Ta5d6ff"Tb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Te6edf3assertEqualsTb4b4b4(T79c0ff2Tb4b4b4, Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4, Ta5d6ff"Ta5d6ffAbsent β†’ present replug should create one fresh transportTa5d6ff"Tb4b4b4)
Te6edf3assertTrueTb4b4b4(Te6edf3initialTransportTb4b4b4.Te6edf3closeCalledTb4b4b4, Ta5d6ff"Ta5d6ffZombie transport must be closed by stopTransportLockedTa5d6ff"Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3initialTransportTb4b4b4.Te6edf3closeCountTb4b4b4, Ta5d6ff"Ta5d6ffZombie transport closed exactly onceTa5d6ff"Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e/**
* Regression: after explicit [SharedRadioInterfaceService.disconnect], the observer MUST be disarmed. disconnect()
* clears `connectionRequested` BEFORE stopTransportLocked(), so even if the selected device reappears the
* observer's first gate (`!connectionRequested β†’ return@withLock`) fires and no transport is resurrected for a
* connection the user tore down. Same shape as the BLE/network post-disconnect recovery regressions above.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`USB replug does not restart transport after explicit disconnect`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Te6edf3clock Tff7b72= T79c0ff0L
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Ta5d6ff"Ta5d6ffs/dev/bus/usb/001/002Ta5d6ff"Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4, Ta5d6ff"Ta5d6ffInitial connect should create one transportTa5d6ff"Tb4b4b4)

T8b949e// Explicit user disconnect: clears connectionRequested gate BEFORE stopTransportLocked(),
T8b949e// nulls radioTransport, and clears runningTransportId. The observer is now disarmed on
T8b949e// multiple gates.
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tff7b72val Te6edf3transportCountAfterDisconnect Tff7b72= Te6edf3createdTransportsTb4b4b4.Te6edf3size

T8b949e// Replug the selected device β€” the observer must observe but not act.
Te6edf3serialDeviceKeysTb4b4b4.Te6edf3value Tff7b72= Te6edf3setOfTb4b4b4(Ta5d6ff"Ta5d6ff/dev/bus/usb/001/002Ta5d6ff"Tb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Te6edf3assertEqualsTb4b4b4(
Te6edf3transportCountAfterDisconnectTb4b4b4,
Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4,
Ta5d6ff"Ta5d6ffReplug after explicit disconnect must NOT restart transport (connectionRequested gate)Ta5d6ff"Tb4b4b4,
Tb4b4b4)
Te6edf3assertFalseTb4b4b4(
Te6edf3serviceTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3value Tff7b72=Tff7b72= Te6edf3ConnectionStateTb4b4b4.Te6edf3ConnectedTb4b4b4,
Ta5d6ff"Ta5d6ffState must remain Disconnected after post-disconnect replugTa5d6ff"Tb4b4b4,
Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e/**
* Regression: the reducer's armed-by-absence state must not survive an explicit Disconnected state. A user can
* unplug while Connected, explicitly disconnect before replugging, then reconnect later. That later normal
* transport-level unplug callback arrives while the selected key is still present; it must not consume stale arm
* state from the previous connection and restart the fresh transport.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`USB recovery arm clears across explicit disconnect before reconnect`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Te6edf3clock Tff7b72= T79c0ff0L
Tff7b72val Te6edf3selectedKey Tff7b72= Ta5d6ff"Ta5d6ff/dev/bus/usb/001/002Ta5d6ff"
Te6edf3serialDeviceKeysTb4b4b4.Te6edf3value Tff7b72= Te6edf3setOfTb4b4b4(Te6edf3selectedKeyTb4b4b4)
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Ta5d6ff"Ta5d6ffsTffd700$Te6edf3selectedKeyTa5d6ff"Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4, Ta5d6ff"Ta5d6ffInitial connect should create one transportTa5d6ff"Tb4b4b4)
Tff7b72val Te6edf3initialTransport Tff7b72= Te6edf3createdTransportsTb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4)

T8b949e// Detach while Connected arms the replug edge but must not restart while the transport is healthy.
Te6edf3serialDeviceKeysTb4b4b4.Te6edf3value Tff7b72= Te6edf3emptySetTb4b4b4(Tb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4, Ta5d6ff"Ta5d6ffDetach while Connected must NOT restart transportTa5d6ff"Tb4b4b4)
Te6edf3assertFalseTb4b4b4(Te6edf3initialTransportTb4b4b4.Te6edf3closeCalledTb4b4b4, Ta5d6ff"Ta5d6ffDetach while Connected must not close the transportTa5d6ff"Tb4b4b4)

T8b949e// Explicit disconnect must clear the armed reducer state.
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tff7b72val Te6edf3transportCountAfterDisconnect Tff7b72= Te6edf3createdTransportsTb4b4b4.Te6edf3size
Te6edf3assertEqualsTb4b4b4(Te6edf3ConnectionStateTb4b4b4.Te6edf3DisconnectedTb4b4b4, Te6edf3serviceTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3valueTb4b4b4, Ta5d6ff"Ta5d6ffPrecondition: disconnectedTa5d6ff"Tb4b4b4)

T8b949e// Replug while disconnected must not preserve stale arm into the next connection.
Te6edf3serialDeviceKeysTb4b4b4.Te6edf3value Tff7b72= Te6edf3setOfTb4b4b4(Te6edf3selectedKeyTb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Te6edf3assertEqualsTb4b4b4(
Te6edf3transportCountAfterDisconnectTb4b4b4,
Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4,
Ta5d6ff"Ta5d6ffReplug while disconnected must NOT restart transportTa5d6ff"Tb4b4b4,
Tb4b4b4)

Te6edf3serviceTb4b4b4.Te6edf3connectTb4b4b4(Tb4b4b4)
Te6edf3serviceTb4b4b4.Te6edf3onConnectTb4b4b4(Tb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Tff7b72val Te6edf3reconnectedTransport Tff7b72= Te6edf3createdTransportsTb4b4b4.Te6edf3lastTb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3transportCountAfterReconnect Tff7b72= Te6edf3createdTransportsTb4b4b4.Te6edf3size
Te6edf3assertEqualsTb4b4b4(Te6edf3ConnectionStateTb4b4b4.Te6edf3ConnectedTb4b4b4, Te6edf3serviceTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3valueTb4b4b4, Ta5d6ff"Ta5d6ffPrecondition: reconnectedTa5d6ff"Tb4b4b4)

Te6edf3serviceTb4b4b4.Te6edf3onDisconnectTb4b4b4(Te6edf3isPermanent Tff7b72= Tff7b72falseTb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Te6edf3assertEqualsTb4b4b4(
Te6edf3transportCountAfterReconnectTb4b4b4,
Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4,
Ta5d6ff"Ta5d6ffNormal unplug after reconnect must NOT restart from stale armed stateTa5d6ff"Tb4b4b4,
Tb4b4b4)
Te6edf3assertFalseTb4b4b4(
Te6edf3reconnectedTransportTb4b4b4.Te6edf3closeCalledTb4b4b4,
Ta5d6ff"Ta5d6ffFresh transport must not be closed by stale recovery armTa5d6ff"Tb4b4b4,
Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e/**
* Regression: duplicate same-value emissions of [serialDevicePresence.deviceKeys] must not produce duplicate
* restarts. StateFlow semantics dedupe equal set re-publications at the source, and the pipeline's
* distinctUntilChanged() coalesces any redundant true β†’ true emissions that slip through, so even a flappy
* publisher that re-emits the same set produces exactly ONE stop+start cycle for one physical replug.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`USB replug duplicate same-key emissions produce exactly one restart`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Te6edf3clock Tff7b72= T79c0ff0L
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Ta5d6ff"Ta5d6ffs/dev/bus/usb/001/002Ta5d6ff"Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4, Ta5d6ff"Ta5d6ffInitial connect should create one transportTa5d6ff"Tb4b4b4)

Te6edf3serviceTb4b4b4.Te6edf3onDisconnectTb4b4b4(Te6edf3isPermanent Tff7b72= Tff7b72falseTb4b4b4)
Te6edf3assertEqualsTb4b4b4(Te6edf3ConnectionStateTb4b4b4.Te6edf3DeviceSleepTb4b4b4, Te6edf3serviceTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3valueTb4b4b4, Ta5d6ff"Ta5d6ffPrecondition: zombie stateTa5d6ff"Tb4b4b4)

T8b949e// First replug: triggers one restart (false β†’ true transition through the pipeline).
Te6edf3serialDeviceKeysTb4b4b4.Te6edf3value Tff7b72= Te6edf3setOfTb4b4b4(Ta5d6ff"Ta5d6ff/dev/bus/usb/001/002Ta5d6ff"Tb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tff7b72val Te6edf3transportsAfterFirstReplug Tff7b72= Te6edf3createdTransportsTb4b4b4.Te6edf3size

T8b949e// Duplicate same-value emission (e.g. a flappy UsbRepository re-publishing the same set).
T8b949e// StateFlow dedupes equal values at the source; distinctUntilChanged() coalesces any
T8b949e// redundant true β†’ true. No additional restart.
Te6edf3serialDeviceKeysTb4b4b4.Te6edf3value Tff7b72= Te6edf3setOfTb4b4b4(Ta5d6ff"Ta5d6ff/dev/bus/usb/001/002Ta5d6ff"Tb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Te6edf3assertEqualsTb4b4b4(
Te6edf3transportsAfterFirstReplugTb4b4b4,
Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4,
Ta5d6ff"Ta5d6ffDuplicate same-key emission must NOT produce a second restartTa5d6ff"Tb4b4b4,
Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(T79c0ff2Tb4b4b4, Te6edf3createdTransportsTb4b4b4.Te6edf3sizeTb4b4b4, Ta5d6ff"Ta5d6ffExactly one restart total (1 initial + 1 replug)Ta5d6ff"Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e// ─── Post-OTA GATT cache invalidation flag lifecycle ───────────────────────────────────────

T8b949e/**
* The one-shot cache-invalidation flag is armed by
* [SharedRadioInterfaceService.requestGattCacheInvalidationOnNextConnect] and consumed exactly once by
* [SharedRadioInterfaceService.consumeGattCacheInvalidationRequest] (atomic getAndSet).
* [SharedRadioInterfaceService.disconnect] must clear any still-pending flag so a later reconnect does not silently
* trigger a stale invalidation.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`gatt cache invalidation flag is consumed once and cleared on disconnect`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Ta5d6ff"Ta5d6ffxAA:BB:CC:DD:EE:FFTa5d6ff"Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3assertFalseTb4b4b4(
Te6edf3serviceTb4b4b4.Te6edf3consumeGattCacheInvalidationRequestTb4b4b4(Tb4b4b4)Tb4b4b4,
Ta5d6ff"Ta5d6ffflag must default to unset before any requestTa5d6ff"Tb4b4b4,
Tb4b4b4)

Te6edf3serviceTb4b4b4.Te6edf3requestGattCacheInvalidationOnNextConnectTb4b4b4(Tb4b4b4)
Te6edf3assertTrueTb4b4b4(
Te6edf3serviceTb4b4b4.Te6edf3consumeGattCacheInvalidationRequestTb4b4b4(Tb4b4b4)Tb4b4b4,
Ta5d6ff"Ta5d6fffirst consume after request must return trueTa5d6ff"Tb4b4b4,
Tb4b4b4)
Te6edf3assertFalseTb4b4b4(
Te6edf3serviceTb4b4b4.Te6edf3consumeGattCacheInvalidationRequestTb4b4b4(Tb4b4b4)Tb4b4b4,
Ta5d6ff"Ta5d6ffsecond consume must return false (one-shot getAndSet)Ta5d6ff"Tb4b4b4,
Tb4b4b4)

T8b949e// Re-arm; disconnect below must clear it.
Te6edf3serviceTb4b4b4.Te6edf3requestGattCacheInvalidationOnNextConnectTb4b4b4(Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}

Te6edf3assertFalseTb4b4b4(
Te6edf3serviceTb4b4b4.Te6edf3consumeGattCacheInvalidationRequestTb4b4b4(Tb4b4b4)Tb4b4b4,
Ta5d6ff"Ta5d6ffdisconnect must clear the pending GATT cache invalidation flagTa5d6ff"Tb4b4b4,
Tb4b4b4)
Tb4b4b4}

T8b949e/**
* Rebinding to a DIFFERENT device address must drop the pending flag β€” the cache invalidation was requested for the
* previous device's post-OTA reboot and must not bleed into the new device's connection.
* ([SharedRadioInterfaceService.setDeviceAddress] clears `gattCacheInvalidationRequested` when the address
* changes.)
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`setDeviceAddress with a different address clears the pending gatt cache invalidation flag`Tb4b4b4(Tb4b4b4) Tff7b72=
Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Ta5d6ff"Ta5d6ffxAA:BB:CC:DD:EE:FFTa5d6ff"Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3requestGattCacheInvalidationOnNextConnectTb4b4b4(Tb4b4b4)

Te6edf3assertTrueTb4b4b4(Te6edf3serviceTb4b4b4.Te6edf3setDeviceAddressTb4b4b4(Ta5d6ff"Ta5d6ffxBB:11:22:33:44:55Ta5d6ff"Tb4b4b4)Tb4b4b4, Ta5d6ff"Ta5d6ffsetDeviceAddress must accept a new addressTa5d6ff"Tb4b4b4)
T8b949e// The clear happens inside the launched transportMutex.withLock; flush it.
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Te6edf3assertFalseTb4b4b4(
Te6edf3serviceTb4b4b4.Te6edf3consumeGattCacheInvalidationRequestTb4b4b4(Tb4b4b4)Tb4b4b4,
Ta5d6ff"Ta5d6ffswitching to a different device address must clear the pending flagTa5d6ff"Tb4b4b4,
Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e/**
* Counterpart: rebinding to the SAME address is a documented no-op ([SharedRadioInterfaceService.setDeviceAddress]
* early-returns when already Connected to that address), so it must NOT touch the pending flag.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`setDeviceAddress with the same address preserves the pending gatt cache invalidation flag`Tb4b4b4(Tb4b4b4) Tff7b72=
Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3address Tff7b72= Ta5d6ff"Ta5d6ffxAA:BB:CC:DD:EE:FFTa5d6ff"
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Te6edf3addressTb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3requestGattCacheInvalidationOnNextConnectTb4b4b4(Tb4b4b4)

Te6edf3assertFalseTb4b4b4(
Te6edf3serviceTb4b4b4.Te6edf3setDeviceAddressTb4b4b4(Te6edf3addressTb4b4b4)Tb4b4b4,
Ta5d6ff"Ta5d6ffsetDeviceAddress with the same connected address is a documented no-op (returns false)Ta5d6ff"Tb4b4b4,
Tb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)

Te6edf3assertTrueTb4b4b4(
Te6edf3serviceTb4b4b4.Te6edf3consumeGattCacheInvalidationRequestTb4b4b4(Tb4b4b4)Tb4b4b4,
Ta5d6ff"Ta5d6ffsame-address rebind must preserve the pending GATT cache invalidation flagTa5d6ff"Tb4b4b4,
Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}

Tf0883e@Test
Tff7b72fun Td2a8ff`setDeviceAddress after deselect preserves pending gatt cache invalidation flag`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTestTb4b4b4(Te6edf3testDispatcherTb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3address Tff7b72= Ta5d6ff"Ta5d6ffxAA:BB:CC:DD:EE:FFTa5d6ff"
Tff7b72val Te6edf3service Tff7b72= Te6edf3createConnectedServiceTb4b4b4(Te6edf3addressTb4b4b4)
Tff7b72try Tb4b4b4{
T8b949e// OTA handler deselects to free the GATT
Te6edf3assertTrueTb4b4b4(Te6edf3serviceTb4b4b4.Te6edf3setDeviceAddressTb4b4b4(Ta5d6ff"Ta5d6ffnTa5d6ff"Tb4b4b4)Tb4b4b4, Ta5d6ff"Ta5d6ffdeselect must succeedTa5d6ff"Tb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

T8b949e// Post-OTA: arm flag, then re-select the SAME device
Te6edf3serviceTb4b4b4.Te6edf3requestGattCacheInvalidationOnNextConnectTb4b4b4(Tb4b4b4)
Te6edf3assertTrueTb4b4b4(Te6edf3serviceTb4b4b4.Te6edf3setDeviceAddressTb4b4b4(Te6edf3addressTb4b4b4)Tb4b4b4, Ta5d6ff"Ta5d6ffre-select must start transportTa5d6ff"Tb4b4b4)
Te6edf3testDispatcherTb4b4b4.Te6edf3schedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Te6edf3assertTrueTb4b4b4(
Te6edf3serviceTb4b4b4.Te6edf3consumeGattCacheInvalidationRequestTb4b4b4(Tb4b4b4)Tb4b4b4,
Ta5d6ff"Ta5d6ffpost-OTA re-select (null to address) must preserve the pending flagTa5d6ff"Tb4b4b4,
Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3disconnectTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)
Tb4b4b4}
Tb4b4b4}
Tb4b4b4}

Served by rngit 1.5.0 - Generated in 0.32s